Front door

The conversational control layer. It turns a request into one structured decision, then deterministic application code answers or hands work to RunManager.

1 min read Updated Sep 2, 2026

Front door

The Front Door is the conversational control layer. It understands what the user wants, selects one product capability or published custom Agent/Workflow when work is required, and hands a structured command to the runtime.

It does no task execution itself.

TEXT
Inbound message
      │
      ▼
FrontDoorService
      ├── ContextBuilder (front_door policy)
      ├── CapabilityIndex
      └── Agno front door
                │
                ▼
         FrontDoorOutcome
                │
                ▼
        FrontDoorService
          /           \
     response        RunManager

The core boundary is:

One structured model decision in; one deterministic application action out.

One turn

Four inputs, one model decision, and exactly one of four outcomes.

StepJob
ContextRead recent conversation, conversation summary, active Runs, already-scoped entities, definition summaries and platform knowledge. User preferences remain deferred with the memory source.
Capability shortlistRead eligible product capabilities and published custom Agents/Workflows for this actor.
DecisionOne Agno call decides whether to answer, clarify, delegate, or control a live Run.
HandoffDeterministic application code validates the decision and calls RunManager when execution is required.
TEXT
FrontDoorOutcome
  answer(text)
  clarify(question)
  delegate(capability_id, input)
  control_run(run_id, action)
OutcomeEffect
answerReply using context already supplied to the Front Door.
clarifyAsk one question because required input or a Run reference is ambiguous.
delegateValidate the selected capability. A product start calls CapabilityStarter.start_resolved(); a custom definition start calls RunManager.start().
control_runValidate the target Run and call RunManager.cancel().

Approval is not a Front Door model outcome. Delegation creates/starts the normal Run path; admission policy may return allow, deny, or require_approval. The Front Door decides what should run. Policy decides whether it may run.

Core code components

TEXT
src/agentic/entry_control/front_door/
  service.py        FrontDoorService
  protocol.py       FrontDoorRuntime, the neutral reasoning port
  agno.py           AgnoFrontDoorRuntime, build_front_door_agent()
  policy.py         FRONT_DOOR_CONTEXT_POLICY
  capabilities.py   CapabilityIndex
  models.py         FrontDoorOutcome, CapabilitySummary, the decision codec

⚠️ The path is src/agentic/. The import-linter contract binds src.agentic as its source module, and a package outside it could import the legacy stack with a green lint-imports. See coexistence, rule 3.

⚠️ The Agno import is split off into agno.py, and that is not tidiness. The contract agno is imported by src.agentic.runtime.agent.agno and nowhere else takes the whole platform as its source, so a build_front_door_agent() beside FrontDoorService fails lint-imports. The Front Door is a second, deliberate Agno caller, so the fix is one more ignored edge and not a hole: the contract gains src.agentic.entry_control.front_door.agno -> agno, and the rest of the package reads FrontDoorRuntime. This mirrors runtime/agent/protocol.py beside runtime/agent/agno/, and it is the same reason: Agno stays a reasoning implementation detail.

entry_control is a new top-level package, so it joins the source list of the src.agentic.surfaces is the top layer contract, and it takes its own contract: services, governance, runtime, shared and capabilities may not import it. inngest_functions is not in that list and cannot be: the registry imports the module that builds each function, exactly as inngest_functions/__init__.py imports runtime/inngest/execute.py for run.execute. inngest_app is not in it either, because the direction is entry_control -> inngest_app. It sits above runtime, because it calls RunManager, and below surfaces, because the conversation router calls it.

ComponentResponsibility
FrontDoorServiceOwn one conversational turn and apply the structured outcome.
FrontDoorRuntimeThe neutral reasoning port. One method, decide.
AgnoFrontDoorRuntimeThe one implementation. It holds every Agno import.
FrontDoorOutcomeClosed structured decision contract.
FRONT_DOOR_CONTEXT_POLICYThe fixed ContextPolicy that keeps the turn to lightweight control and already-scoped context.
CapabilityIndexFind a small shortlist of published Agents/Workflows.
CapabilitySummaryMinimal routing DTO exposed to the model.
RunManagerProduct-facing Run lifecycle boundary used for start and cancel.

No separate DelegateHandler or CancelHandler class is required in V1. FrontDoorService translates the structured outcome directly into RunManager operations.

Agno construction

The Agno agent is deliberately thin.

PYTHON
def build_front_door_agent(model) -> Agent:
    return Agent(
        model=model,
        instructions=FRONT_DOOR_INSTRUCTIONS,
        output_schema=FrontDoorOutcome,
    )

⚠️ The model is a constant, and it must be a priced one. A turn names no definition, so it has no snapshot and no ModelConfig to read. FRONT_DOOR_MODEL sits beside build_front_door_agent() and names a pair that MODEL_REGISTRY holds and MODEL_PRICING prices. An unpriced model bills 0.00, the organization day sum never moves, and the ceiling that gates the next turn can never fire.

There are no AgencyCore action tools attached to this agent. In particular, delegate and cancel are not model-callable execution tools. They are deterministic application operations performed only after the structured result is validated.

This keeps Agno responsible for reasoning, while AgencyCore remains responsible for authorization, Run state, validation, and execution.

FrontDoorService never names Agno. It takes a FrontDoorRuntime, which answers one question:

PYTHON
@dataclass(frozen=True)
class FrontDoorTurn:
    text: str
    actor: ActorIdentity
    conversation_id: UUID
    context: ContextBrief
    capabilities: Shortlist


class FrontDoorRuntime(Protocol):
    async def decide(self, turn: FrontDoorTurn) -> FrontDoorOutcome: ...

⚠️ The runtime writes the meter row, and the service does not. The row exists because the vendor charged, the charge happens inside this one method, and the vendor response is the only place the token counts exist. AgnoFrontDoorRuntime therefore takes a UsageRecorder, exactly as the agent loop's bridge.py builds its own UsageRecord where the response lands.

This is what makes the retry correct. A memoized replay makes no vendor call, so it must write no second row, and one write outside the memoized step would write one on every attempt. Putting the row where the charge is needs no second key and no second guard. It also leaves FrontDoorOutcome the only thing the step stores, and that is already a pydantic model, so the codec is model_dump_json and model_validate_json.

⚠️ A lost row never fails the turn. UsageMeter.record raises MeteringUnavailable. A run that meets it ends; a turn has no run to end, and the decision is already paid for. The runtime catches it and logs it. A raise here would leave the memoized step failing, and Inngest would retry it into a second vendor call, which is the one outcome worth more than the row.

⚠️ decide raises, and it answers no failure member. A vendor that returns prose instead of the schema, and a provider that refuses, are both FrontDoorUnavailable. The turn then fails cleanly and creates no run. A tuple with a nullable outcome would make every caller test for a case that has one handler.

⚠️ A failed call is not metered. The tokens of a refused parse are spent and no row records them. The vendor response that carries the counts is the response that did not arrive, so the alternative is a guess. The day ceiling under-counts by the failures, and the failures are rare.

⚠️ The port is the memoization seam. The durable turn function wraps this one method in a memoized step keyed on the message id, so a retry replays the decision, makes no vendor call and writes no second meter row. FrontDoorService is unchanged by it, because a memoizing runtime satisfies the same protocol.

FrontDoorService

FrontDoorService owns the turn.

PYTHON
class FrontDoorService:
    async def handle(
        self,
        message: NormalizedMessage,
        actor: ActorIdentity,
        conversation,
        on_progress: Callable[[str], None] = lambda _state: None,
    ):
        gate = await self.accrual.check(
            actor.organization_id,
            root_run_id=None,
            ceilings=None,
            scope='day',
            principal=None,          # no run exists yet, so there is no grant
        )
        if gate.outcome == 'deny':
            return self.day_limit_reached(gate.reason)

        on_progress("thinking")
        on_progress("checking_context")
        entity_refs = await self.entity_scope.list_entity_refs(
            conversation.id,
            actor.organization_id,
            limit=20,
        )

        context = await self.context_builder.build(          # the shared ContextBuilder
            ContextRequest(
                subject=ContextSubject.of_actor(actor),
                query=message.text,
                entities=entity_refs,
                conversation_id=conversation.id,
            ),
            FRONT_DOOR_CONTEXT_POLICY,
        )

        on_progress("finding_capability")
        capabilities = await self.capability_index.search(actor)

        outcome = await self.runtime.decide(          # it writes its own meter row
            FrontDoorTurn(
                text=message.text,
                actor=actor,
                conversation_id=conversation.id,
                context=context,
                capabilities=capabilities,
            )
        )

        return await self.apply(
            outcome=outcome,
            actor=actor,
            message=message,
            conversation=conversation,
            capabilities=capabilities,
            on_progress=on_progress,     # apply() emits preparing_task
        )

⚠️ The turn holds an ActorIdentity, and it never holds a Principal. Principal is the frozen authority of one run: it carries run_id and definition_id, and both are required. A Front Door turn has neither, so a principal here could only be a fake one, and a fake authority is the thing the policy plane exists to refuse. RunManager mints the real principal when the delegate outcome starts a run.

⚠️ ContextRequest therefore takes a subject, not a principal. ContextSubject carries the four fields the sources actually read: organization, user, trigger and definition. Principal.subject answers one, so the run path is unchanged, and ContextSubject.of_actor() answers one for a turn with no definition. Without this seam the Front Door cannot call the shared builder at all. State and knowledge owns the shape.

apply() is deterministic. It validates identifiers and translates the outcome into either a response or one RunManager command.

The turn is metered, and it is gated

The Front Door makes one model call per inbound message, and that call happens before any Run exists. Without the two lines above it would write no usage row and pass no checkpoint, so accrual policy would never see it and no ceiling would stop it. A busy shared channel would then spend with no bound but the addressing rule.

TEXT
before the model call   AccrualChecker.check(...)  -> PolicyDecision   deny -> answer that the day limit is reached
after the model call    one ai_usage_log row, agent_root_run_id null, conversation id in metadata

⚠️ The conversation is recorded in metadata, and it takes no column. public.ai_usage_log has no conversation_id, and the day sum reads every row of the organization with no run filter, so the ceiling already covers a turn that names no run. A column would be a migration on the busiest table in the product to hold a value nothing queries. UsageRecord.root_run_id becomes UUID | None to match the column, which is already nullable.

⚠️ A denied turn writes no usage row. The gate runs before the model call, so a refusal spends nothing and records nothing. "One row per turn" means one row per turn that reaches the vendor.

Both reuse what already exists. AccrualChecker is the same component the runtime calls before each segment, it returns a PolicyDecision here exactly as it does there, and ai_usage_log is the same canonical meter. The Front Door adds no counter of its own.

A run has no principal yet at this point, so the check reads the organization day ceiling only. There is no run budget to read. See policy and governance.

A null root run id is the honest record: the spend belongs to a conversation, not to a Run. The organization day total sums both, so one ceiling covers routing and execution together.

Outcome contract

The model shape. CapabilityId is the closed set of five IDs in the capability contract. It lives in src/agentic/shared/capabilities.py.

ResourceRef is the {kind, id, label} pointer to one product row, in src/agentic/shared/refs.py. State and knowledge owns it: the same type carries ContextRequest.entities and ContextBrief.items, which is what makes the citation check below a set membership and not a parse.

PYTHON
class FrontDoorAnswer(BaseModel):
    text: str                                 # nonblank
    citations: list[ResourceRef] = []         # refs of the supplied brief only
    unresolved: list[str] = []                # the facts the brief did not carry


class FrontDoorOutcome(BaseModel):
    kind: Literal["answer", "clarify", "delegate", "control_run"]

    answer: FrontDoorAnswer | None = None
    question: str | None = None

    capability_id: CapabilityId | UUID | None = None
    input: dict | None = None

    run_id: UUID | None = None
    action: Literal["cancel"] | None = None

⚠️ An answer carries its evidence, and the service checks it. A citation naming a ref the context brief did not supply refuses the whole decision, so the model can neither cite the capability shortlist nor invent a row. unresolved is where a missing fact goes, rather than a citation that reads as a source.

⚠️ Every field is optional, so the kind must be made total by a validator. Read as written, an answer with a null payload parses, and the turn then has a decision it cannot apply. One model_validator requires the fields of each kind, and refuses the fields of the other three:

TEXT
answer       answer                    and nothing else
clarify      question                  and nothing else
delegate     capability_id             input is optional
control_run  run_id and action         and nothing else

⚠️ The union order is CapabilityId | UUID, and it is not free. The five IDs are a Literal, so a UUID string fails that member and parses as the second. A string that is neither refuses the whole decision, and the turn then fails rather than answering. That is the correct trade: the model reads the shortlist in the same call, so an ID outside both formats is a broken vendor answer and not a routing miss.

Validation rules:

  • delegate.capability_id must be one of the candidates supplied to the model.
  • RunManager resolves the definition again before execution. The shortlist is routing information, never execution truth.
  • A product ID requires an input that passes that capability's published schema. Do not add a text field.
  • A custom definition UUID keeps the existing text fallback: {'text': message.text} | (outcome.input or {}). The UUID and product-ID formats are disjoint; no second outcome and no selector tag is needed.

⚠️ The outcome carries no contract_version, and the turn re-resolves no binding. Both were in an earlier draft of this page, and both are redundant on this path. The shortlist read, the schema the model reads and the executor the turn starts all come from one registry read of one turn, so there is no window in which the model could echo a version the turn did not just supply. A second resolve would cost two more reads on the hottest path of the product, refuse a start whose input the current schema accepts, and start the new executor after a mid-turn upgrade, while the design rule for an admitted Run is that it keeps the binding it was admitted against.

⚠️ A product start still goes through the shared capability start service. ENG-2329. The turn calls CapabilityStarter.start_resolved() and passes the capability ID, the contract version, the executor and the input schema of its one registry read. That entry point reads the registry never, so the rule above holds. It owns the request digest, the capability-start key namespace and the version pin, so a chat start and an API start of one product request follow one replay rule. Before it, the turn called RunManager.start() directly: a chat start carried no digest and keyed its delivery in the generic definition namespace, so a redelivered message with edited input replayed the earlier Run instead of answering idempotency_conflict.

The turn pins the version of its own read, so an executor upgraded between the shortlist read and the start no longer matches. RunManager refuses that start rather than running the new executor on input the model wrote for the old schema. The person reads that the capability changed, and asks again.

The direct start API is the opposite case and reads the registry itself. Its client reads a contract out of band, caches it, and builds a body against it hours later, so CapabilityStarter.start() resolves the ID again and compares the requested version. Surfaces owns that route, its contract_version body field and its contract_version_conflict answer.

  • control_run.run_id must resolve to a Run of the actor's organization. RunRepository.get(run_id, organization_id) answers it, exactly as _shared/org_scope.require_run does. RunManager.cancel() filters on the actor's organization itself, so this read is the second tenancy gate and not the only one. It is still required: cancel() returns None for a foreign run and for a run that does not exist, so only this read separates "I stopped it" from "I could not find that job".
  • A terminal run is reported as terminal, on both paths. cancel() matches zero rows on one, and RunManager.start() answers duplicate with whatever status the first delivery's run now holds. Told "I stopped that" for a run that succeeded, or "that is already running" for one that finished yesterday, a person waits for an answer they already have.
  • If “stop that” or “change it” could refer to more than one active Run, the outcome must be clarify; never guess.
  • Steering is deferred to V2. A person who wants to change a live Run cancels it, then starts it again with new input. That holds for an Agent Run and for a Workflow Run.

One product delegation per turn

The Front Door can select company.search, company.enrich, people.search, people.enrich or signals.search. It receives eligible IDs, product descriptions and a bounded projection of each input schema. It receives no provider tools, no executor configuration and no executor UUID. An unknown, unauthorized or unavailable selection cannot start work.

A missing required field produces one concrete clarification and no Run, and two layers produce it. The prompt asks the model for clarify when it does not hold a required field, and that is the normal path. apply() then validates the written input against the same schema, and a failure there becomes one deterministic clarification naming the failing field paths. It starts no Run.

⚠️ The deterministic clarification names field paths, and that is safe. A field name is the published product contract, which the read API already serves to any caller holding the capability's scopes. It is not executor configuration. Bound the sentence to the first three failing paths, so a wholly wrong input cannot render a wall of text.

A request such as "find companies, enrich them, then find their directors" is a compound plan. Ask which operation to start unless one published capability already covers the whole requested result. Do not silently execute the first step and imply that the remaining steps will follow. A company brief inside People Search is allowed because the published workflow owns that child composition.

Keep answer, clarify, delegate and control_run as the four outcomes. Cancel remains a deterministic RunManager operation. Live steering and general multi-capability planning remain deferred. The model does not gain callable run_capability, steer_run or cancel_run tools.

CapabilityIndex

The Front Door discovers product capabilities and custom Agents/Workflows, never individual tools.

PYTHON
class CapabilityIndex:
    async def search(self, actor: ActorIdentity) -> Shortlist: ...


@dataclass(frozen=True)
class Shortlist:
    products: list[ProductSummary]      # at most PRODUCT_LIMIT (5)
    custom: list[CapabilitySummary]     # at most SHORTLIST_LIMIT (10)
PYTHON
@dataclass(frozen=True)
class CapabilitySummary:
    id: UUID
    kind: Literal["agent", "workflow"]
    name: str
    description: str | None


@dataclass(frozen=True)
class ProductSummary:
    capability_id: CapabilityId
    name: str
    description: str
    input_schema: dict[str, Any]
    executor_id: UUID

⚠️ executor_id is on the summary, and it never reaches the model. The turn resolved the record already, so a second registry read before the start would buy nothing and cost two statements. _render() prints the stable ID alone, and one test asserts that no executor UUID appears in the prompt. It is the same rule the custom half already keeps for prompts and configuration: the index carries what the turn needs, and renders what the model may read.

⚠️ Two lists, and not one tagged union. The two halves take different input rules, so the model must be able to tell them apart: a product start carries schema-shaped input and no text, and a custom start carries the message text. A discriminator field on one list would make the model read the tag before the rule, and a wrong tag would then pick the wrong input rule silently.

The index combines the product registry with the existing definition reads:

TEXT
CapabilityIndex
   ├── CallerRights.held_by(actor): the rights this caller holds now
   ↓   (no run.start → two empty halves, and the three reads below never run)
   ├── CapabilityRegistry.list_capabilities(actor): the available records of the five
   ├── DefinitionRepository.list_page(org, kind='agent',    origin='custom', state='active')
   └── DefinitionRepository.list_page(org, kind='workflow', origin='custom', state='active')

The last three reads run together. The registry read makes two statements of its own, so one delegating turn costs four reads and two waves.

⚠️ The rights are read before both halves, and one answer decides them. ENG-2341. CapabilityRegistry applied this rule to the product half alone, so an actor that could start no run still read every custom Agent and Workflow of the tenant, with names and descriptions. That is a disclosure gap and not a privilege escalation: RunManager mints the Principal and applies admission again, so the actor started nothing. A refusal is the same for every capability, so the branch returns before any read and costs nothing.

⚠️ CapabilityRegistry keeps its own check, and the pair is not redundant.resolve() is a start path and the read routes of surfaces/capabilities reach it, so both entry points must fail closed on their own.

⚠️ Both readers take one PrincipalFactory. RoleRights holds the 30-second cache, so the second read costs nothing and the two answers cannot disagree inside one turn. Two factories over two RoleRights would read the map twice and could split on a role change.

⚠️ A registry fault fails the turn, and it is not swallowed. An empty product half reads to the model as a tenant that installed nothing, so the turn would answer that no published capability can do the work and the person would go and build one. The custom half already fails the turn the same way, so the two halves behave alike.

The custom path makes two definition reads and merges them on (created_at, id) descending. Exclude product-bound definitions, including inactive historical executors, from this custom projection.

⚠️ The exclusion is a boundary and not tidiness. A provisioned executor is a custom, active workflow of the tenant, so the unfiltered custom read returns it beside its own product record. The model could then delegate to it by UUID, and the UUID path adds the message text and validates nothing. The tenant would run company.search on an input its closed schema refuses. Filter on the row carrying capability metadata, not on the active binding: an inactive historical executor is the same hole.

Keep the existing ten-custom-definition allowance and reserve up to five additional slots for eligible products. The two budgets are separate counters, so five recent custom definitions cannot displace a product and a full product set cannot narrow custom routing.

Rendering a schema

⚠️ A published input schema may be 32,768 bytes, and the whole Front Door context budget is 8,500 tokens. Five raw schemas can exceed the budget of the turn that reads them. The prompt therefore renders a compact projection and never the raw JSON Schema: for each root property, the name, whether it is required, the type, any enum values, and the bounds a person would need to fill it in.

One cap covers one capability, not the section. The vocabulary holds five stable IDs, so the section is bounded by five times that number, and a capability renders the same whatever else the tenant installed. A cap over the section would let the first capability spend what the fifth needs.

Over the cap, keep every required field, then take the optional fields in declared order until the next one would not fit. Render the kept fields in declared order: a reordered list reads to the model as a different contract.

⚠️ A required field is never dropped. Truncating one leaves the model writing input that fails validation on every attempt, and the person then reads a clarification for a field the prompt never showed. If the required fields alone exceed the cap, drop the whole capability from the shortlist and log it: a capability that cannot be described cannot be routed to.

⚠️ A reference hop and a branch hop cost no depth, so depth cannot bound the walk. The V1 shapes publish array of $ref -> oneOf -> object, and a budget that charged for all three renders every tagged union of the contract as an unnamed value. A schema is a finite tree, so a cycle can only run through a reference: follow each reference at most once on one path. Pydantic emits a self-referencing $defs union for a recursive discriminated union, so it is a schema a tenant can publish.

⚠️ origin is custom, and the default union is wrong here. list_page with no origin also returns the platform templates that are active. DefinitionResolver filters on organization_id alone, so a template resolves for no tenant. A shortlisted template is therefore a delegate that always answers definition_not_found. A template is a fork source, and it is not a capability.

⚠️ description is a new optional key of the agent config and of the workflow config. AGENT_FIELDS and WORKFLOW_FIELDS are closed sets, and read_keys refuses an unknown key, so the field must be declared before an author can write one. The skill validator already carries the same key. Definition holds the name alone, so the index reads the description out of published_config.

There is no AgentDefinitionRepository and no WorkflowDefinitionRepository. One repository shape serves agents, workflows and skills. See runtime definitions.

Rules:

  • Return only published and enabled capabilities discoverable by the organization/principal.
  • Draft Specs never appear in the index.
  • Return routing summaries and product input schemas only: no prompts, skills, Tool Registry schemas, or complete executor definitions.
  • Policy admission remains authoritative; capability visibility is not authorization.
  • Product lookup uses stable IDs. Custom discovery retains its bounded newest-first definition read.
  • Add a lexical filter on the day a catalogue outgrows one page, and embeddings only after that.
  • Keep at most five product and ten custom candidates, on two separate counters.
  • Render a compact schema projection under one byte cap per capability. Never render a raw published schema, and never render an executor UUID.

Front Door context

Front Door context is intentionally smaller than execution context. It is not a second builder. The Front Door calls the shared ContextBuilder from state and knowledge with one fixed platform policy.

PYTHON
FRONT_DOOR_CONTEXT_POLICY = ContextPolicy(
    total_token_budget=...,
    refresh_on_resume=False,
    sources=[
        ContextSourceSpec(name='conversation', token_budget=..., limit=20),
        ContextSourceSpec(name='application', token_budget=..., limit=20),
        ContextSourceSpec(name='definition_summary', token_budget=..., limit=20),
        ContextSourceSpec(
            name='knowledge',
            token_budget=...,
            limit=8,
            options={'include_platform': True},
        ),
        ContextSourceSpec(name='run_history', token_budget=..., limit=5),
    ],
)

entity_refs = await conversation_scope.list_entity_refs(
    conversation.id,
    actor.organization_id,
    limit=20,
)

context = await context_builder.build(
    ContextRequest(
        subject=ContextSubject.of_actor(actor),
        query=message.text,
        entities=entity_refs,
        conversation_id=conversation.id,
    ),
    FRONT_DOOR_CONTEXT_POLICY,
)

One builder means one budget model, one failure model and one set of tenancy tests. A private Front Door builder would need all three again.

The sources the current slice enables

SourceAnswersState
conversation“What did we decide earlier?”New. It reads agent.conversation_messages and the conversation summary.
application“What is true about this scoped company?”Resolves supplied ResourceRefs only.
definition_summary“What visible Agents or Workflows can do this?”Reads safe metadata for visible published definitions.
knowledge“What do AgencyCore docs say?”Retrieves bounded ready platform chunks.
run_history“Is my company search still running?”Built in Phase 3.

Entity scope is durable pointer state

⚠️ The conversation scope stores refs, not facts. agent.conversation_entities records conversation-scoped ResourceRef pointers with a label, the message that introduced them and the last reference time. The row is a convenience index. The current body still comes from the product reader that owns the ref kind.

ApplicationContextSource resolves request.entities and never searches request.query. A malformed, unsupported, stale or foreign ref drops before it reaches model context. If exactly one readable company ref fits “this company”, the answer can use it. If more than one fits, the Front Door asks which one instead of guessing.

The source that is deferred, and why

⚠️ memory cannot scope a Front Door turn. MemoryContextSource filters on subject.definition_id, because a memory belongs to the agent that wrote it. A turn names no definition, so the source has nothing to filter by and returns nothing. Widening memory to a definition-less read is a change to a Phase 3 component and its precedence tests, so it is its own ticket.

The policy allows control context and already-scoped context. It enables no exploratory CRM, email, web or research source. Those are task work, and task work is delegated.

Shared Run start contract

Front Door and Triggers use the same runtime boundary. Runtime execution owns the shape of StartRunCommand, and the Front Door must not create a second start contract.

The Front Door passes the actor, not a minted Principal. RunManager mints the Principal, because the intersection needs the definition and the Run ID. The idempotency_key the Front Door supplies protects retries or duplicate inbound delivery from creating duplicate Runs.

A product start reaches that boundary through CapabilityStarter.start_resolved(), which is the shared capability start service and not a second start contract. It namespaces the delivery key, builds the request digest and passes one StartRunCommand. A custom definition start calls RunManager.start() directly and keeps the generic delivery key.

Delegate and cancel

Delegate

TEXT
delegate outcome
      │
      ▼
validate capability id
      │
      ├── product ──> CapabilityStarter.start_resolved()
      │                   digest, key namespace, version pin
      │
      ▼
StartRunCommand
      │
      ▼
RunManager.start()
      │
      ▼
Policy admission
   ┌─────────┼────────────────┐
   ▼         ▼                ▼
 allow      deny      require_approval
   │         │                │
   ▼         ▼                ▼
dispatch   reject      approval + wait

The Front Door never inserts a Run row directly and never calls Inngest directly.

Cancel

TEXT
control_run(cancel)
      │
      ▼
validate Run reference
      │
      ▼
RunManager.cancel()
      │
      ▼
product state + runtime cancellation

Cancellation is idempotent. Cancelling an already-cancelled Run is still success.

Progress ownership

⚠️ There is no FrontDoorProgressEmitter. Open decision 4 below is answered: the durable turn function holds the conversation publisher, so it publishes these events, and handle() takes an on_progress callback so it can report the two phases that happen inside it. A callback is a parameter with a no-op default. A protocol would be a component, an injected seam and a fake, on the hottest path of the platform, for one call.

⚠️ Two of the four states are only visible from inside handle(). checking_context fires before the entity-scope and context reads, and finding_capability fires before the capability index and decision read. A turn function that wrapped handle() from outside could publish thinking and preparing_task and nothing between them, which is the whole span the person is waiting through. That is why the callback exists and the component does not.

The turn publishes a very small semantic vocabulary while the Front Door owns the work:

TEXT
thinking
checking_context
finding_capability
preparing_task

The Gateway decides how to render or drop these events for each channel. They are semantic UI state, not chain-of-thought.

TEXT
Front Door progress ---- delegation ----> Run progress
       owner                                  owner
    Front Door                            Runtime/Run

When delegation begins:

  1. RunManager.start() answers started, duplicate, or a refusal. A duplicate is not a new turn. The channel gateway seeds the key from the inbound message id, so a redelivered channel message returns the Run the first delivery created. The turn attaches the stream to that Run and publishes no preparing_task, because the work is already under way and a second progress line would double-render it. StartRunResult.outcome is what says which case it was, so the turn reads it and the service returns it.
  2. If RunManager.start() answers started with a non-terminal Run, the turn publishes preparing_task.
  3. Once a Run owns the work, the turn publishes no more task progress.
  4. Subsequent progress comes from normalized Run events/SSE.

Do not let Front Door and Runtime emit overlapping progress for the same task.

What it must never do

It must notBecauseOwner
Run long workDelegated work outlives the conversational turnRuntime / Run
Do deep research or exploratory business readsThat requires task tools and a budgetAgent / Workflow
Manage individual toolsFront Door selects capabilities; Agents select toolsAgent definition / tool layer
Decide approval conditionsGovernance must behave consistently on every pathPolicy plane
Step through or mutate a WorkflowWorkflow coordination is deterministic executionWorkflow executor
Trust model-selected IDs blindlyModel output is not authorizationdeterministic validation + RunManager
Insert Run rows or call InngestThat creates a second lifecycle pathRunManager

Each forbidden responsibility is a path toward turning the Front Door into a second runtime.

Failure handling

FailureBehaviour
No suitable capabilityClarify if the intent is ambiguous; otherwise explain that no published capability can perform the task. ⚠️ Only a catalogue refusal says this. A spent budget, a snapshot that will not build and a policy outage each get their own sentence, and StartRunResult.reason is written for an operator, so it is logged and never shown.
Hallucinated capability idReject deterministically; never start a Run. An ID in neither format refuses the decision itself, and the turn fails.
Product input fails its schemaOne deterministic clarification naming the first three failing paths. No Run.
Capability disabled after shortlistRunManager.start() resolves again and fails closed.
Capability upgraded after shortlistNo Run starts. The turn pins the contract version of its own registry read, so RunManager finds a binding that no longer matches and answers capability_unavailable. The person reads that the capability changed, and the next turn reads the new contract.
Ambiguous Run referenceAsk one clarification question.
Admission deniedReturn the policy denial reason; do not retry through another path. RunManager carries it on StartRunResult.reason beside error_code, and a refusal with a null reason still names a rule rather than the catalogue.
Admission requires approvalReturn/stream the waiting state owned by the Run; approval machinery owns resolution.
Front Door model failureFail the turn cleanly; do not create a Run from partial output.
Duplicate inbound requestReuse the shared idempotency key so RunManager.start() does not create duplicate work. A product start also compares the request digest, so a redelivery carrying edited input answers idempotency_conflict and starts nothing.
The worker retries the turnThe model call is memoized on the message id, so the retry re-reads the decision and does not re-charge.
The worker retries after the answer was writtenThe assistant message is keyed by the message it answers, so the second write updates one row.

The whole turn is idempotent, and not only the delegate half

RunManager.start() already protects delegation: the idempotency key derives from NormalizedMessage.message_id, so a redelivered message returns the Run the first delivery created. A product start namespaces that key under capability: and compares the request digest too, so a redelivery whose input the turn wrote differently answers idempotency_conflict rather than replaying the earlier Run.

⚠️ answer and clarify have no such guard, and they need one. A turn runs on the durable worker, so any retry re-runs the whole body. Without a key, one retry after a successful model call charges the model twice and appends a second assistant bubble to the conversation.

Two rules close it.

  • The model call runs inside one memoized durable step, keyed by the message id. A retry replays the stored decision and makes no vendor call.
  • The assistant message carries in_reply_to = the inbound message id, under a unique index. A retry that reaches the write updates the row it already wrote.

V1 decisions

DecisionV1 choice
Front Door frameworkAgno, in-process, behind FrontDoorRuntime
Model hopsOne
RoutingDeterministic capability shortlist + one structured model decision
Executable targetsOne of five product capabilities by stable ID, or a published custom Agent/Workflow by UUID
Product inputValidated against the published schema of the same turn's registry read. No version echo, no second resolve
Execution boundaryRunManager
Approval ownerPolicy + approval service; Runtime owns wait/resume
Run controlIdempotent cancel only. Steering is V2
Capability searchThe newest active capabilities of the organization, capped. No query in V1
Action tools on Front DoorNone
Platform documentationBounded ready platform chunks from the knowledge source
ProgressFour semantic events, then hand off to Run progress

Rules

  • It plans dispatch, not execution. It chooses one Agent or Workflow and supplies the input.
  • One model call; no routing model before it. Capability search is deterministic retrieval, not another agentic hop.
  • It selects a capability, never a tool. The selected Agent owns tool selection through its definition.
  • It selects a product by its stable ID, never by its executor UUID. The executor UUID is turn state; the stable ID is the product contract.
  • One agentic hop. A delegated Agent may create child Runs through the runtime, but it never calls another Front Door.
  • Every delegation goes through RunManager. There is no private synchronous execution path.
  • Ask before dispatch when required input is missing. One clarification is cheaper than a failed Run.
  • Policy owns approval. The Front Door contains no rules such as “500 emails requires approval”.
  • Platform questions are knowledge questions. Relevant AgencyCore documentation may be answered directly from retrieved platform context. When retrieval returns no relevant chunk, the Front Door says the available docs do not contain enough information.
  • Fresh business exploration is delegated. CRM searches, email searches, web research, and external-system work belong to runtime Agents/Workflows.
  • The model never writes Run state. Structured output is validated before application code acts.
  • Every turn is metered and gated. One usage row per turn, and one accrual check before the model call.
  • Agno remains a reasoning implementation detail. Product Run state, policy, context ownership, and execution boundaries belong to AgencyCore.

It runs on Agno in-process and must remain fast. It needs no sandbox, MCP server, skill execution, workflow engine, durable agent session, or tool catalogue of its own.

Open decisions

  1. Should the Front Door use an Agno session, or remain stateless over AgencyCore-owned conversation state? Decide with a latency benchmark.
  2. At what catalogue size does lexical capability search stop being good enough?
  3. How much platform documentation may consume the Front Door context budget per turn? Decided: 1,500 tokens across up to eight chunks. This keeps the whole Front Door context budget at 8,500 tokens.
  4. Can FrontDoorProgressEmitter be deleted? Decided: yes. The durable turn function publishes the four events. See Progress ownership.
  5. Where does a conversation store its entity scope? Decided: agent.conversation_entities. The turn passes the current scoped refs to ContextRequest.entities. See Entity scope is durable pointer state.

Minimum contract tests

TEXT
answer outcome never creates a Run
clarify outcome never creates a Run
an outcome missing the field of its own kind is refused
delegate accepts only a supplied capability id
delegate calls RunManager.start exactly once
custom delegate input carries the message-text fallback
product delegate input carries no text key
product delegate input that fails its schema returns clarify and creates no Run
a product start passes the executor uuid the shortlist resolved
compound requests never create an implicit multi-start plan
product shortlist entries cannot be displaced by recent custom definitions
a product-bound definition never appears in the custom shortlist
the prompt holds no executor uuid and no raw published schema
a required schema field is never dropped by the byte cap
StartRunCommand carries source + conversation + idempotency key
the index returns no skill, no draft, and no platform template
policy denial cannot be bypassed by Front Door
a require_approval admission becomes waiting Run state
a disabled capability answers definition_not_published, and the turn reports it
control_run refuses a Run of another organization
ambiguous Run control returns clarify
cancel is idempotent
Front Door never receives the tool registry
a turn that reaches the vendor writes one usage row, with a null root run id
the usage row carries the conversation id in metadata
a turn over the organization day ceiling makes no model call, and writes no row
a memoized replay makes no vendor call and writes no second usage row
the turn holds an actor, and mints no Principal

The turn function owns three more, because it owns the durable step and the conversation write:

TEXT
progress stops after Run ownership begins
a retried turn makes one model call
a retried turn writes one assistant message, not two