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 Oct 6, 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, platform knowledge and durable memory.
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.

The draft agent of a form chat may hold four read tools and nothing else. They read the web, up to two pages and the CRM, and none of them starts a run or writes a record. A draft turn researches states them.

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.

The shape of the instructions

FRONT_DOOR_INSTRUCTIONS is written as named blocks, and the order is the skeleton of system prompt architecture: the role, the closed outcome set, then one block per outcome, then the id rule and the security rule. Each rule states one idea, and each hard case carries a worked example.

Before ENG-2397 it was 16 flat rules under one heading. Each rule had arrived from one bug, and no rule sat near the outcome it governed.

⚠️ A rule scores where the model reads it, and not where an outline puts it. The rewrite was measured at every step against both recorded evaluations, and three regressions came from moving a rule away from its outcome. Each one was repaired by moving one sentence back.

ArmWhat changedRoutingAnswer outcomeCitation support
controlthe trunk31/350.86780.9028
Anamed blocks, no rule added or cut29/35——
A2A, two rules back beside delegate31/35——
Dthe routing matrix rows added31/350.9330.809
ED, the citation contract back beside answer31/350.9250.937
FE, nine defects of the text repaired31/350.92560.9296

Routing is measured twice on the shipped text, and both runs agree request for request with the record before it. Only the instructions digest separates the two fingerprints, so the comparison is controlled.

Answer quality rises on seven decisions of 121, one more supported citation, and every family. The rubric loses one graded answer of 79, and its rate falls further because three more cases now answer and so carry prose a judge grades. Read the pass count, and not the rate.

Arm A changed no rule, and it moved two of them. It scored two routing requests below the control: "no capability fits, so answer" moved into the answer block, and "clarify for the one missing field" moved into the clarify block. Both had sat inside the delegate rules. FD16 then started a capability against an empty shortlist, and FD29 guessed a field the schema refused.

Arm D reached the best decision accuracy of any arm and wrote 13 unsupported citations of 68, where the control wrote 5 of 66. The citation contract sat in a trailing block. Arm E moved it under the answer block and the count fell to 4 of 64.

Every number here comes from two paid passes: test_routing_accuracy_eval.py at 33 requests and 3 samples, and test_answer_quality_replay.py over the 120 committed recordings. Both name claude-haiku-4-5 at temperature 0.

⚠️ Arm F trades one citation for seven decisions, and the trade is deliberate. A review of arm E found nine defects in the text: a block that stated four reasons and listed five, a capability id rule that had lost the word only, two rules that sent one request to two outcomes, and five rules the rewrite had dropped. Arm F repaired all nine. Decisions rose from 105 of 120 to 112, and the rubric from 75 of 90 to 82 of 92. Citation support fell from 61 of 66 to 60 of 66.

Read the supported count, and not the ratio. It is 60 under arm E and under arm F. Arm F writes two more citations than arm E and neither is supported, so the ratio moves and the evidence the person reads does not. An unsupported citation costs the citation and never the turn, and a wrong decision costs the whole turn.

⚠️ A move between blocks is a scored change, even when it edits no word. Re-run both passes for one.

⚠️ The negative rules are stated as instructions. "Never invent an id" became "Read every id from such a bracket, or from the scoped rows block", with "Clarify and ask for the row when neither place holds it" beside it. The architecture page names that exact negative as the anti-pattern and this rewrite as the fix. The invariant still holds three ways: the rule names both sources, it names the one move for a row neither source holds, and apply() refuses an id the shortlist or clarify_input does not accept.

⚠️ A rule that restates a contract invariant stays, whatever it scores. The four outcomes, the absence of action tools, and the exploratory-read refusal are safety rules the case sets may never exercise. Do not delete one for measuring zero.

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 for the model call of a turn that reaches the vendor. A draft turn that researches also writes one row for each paid search and fetch.

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 product 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 model's gaps + the citations the service dropped


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 settles it. A citation naming a ref the context brief did not supply is dropped, so the model can neither cite the capability shortlist nor invent a row. A kept citation is persisted as the brief's own ref, so one row is stored one way whichever of its two words the model wrote. unresolved is where a missing fact goes, rather than a citation that reads as a source, and _settled_citations records each dropped citation there for the operator. ⚠️ unresolved therefore carries two authors, and a reader that wants the model's own gaps alone must not assume every line is one. record_dropped_citations counts the drops, because a model that invents every citation now ships answers and raises nothing.

⚠️ An unsupported citation costs the citation, never the turn (ENG-2419). It raised FrontDoorUnavailable, which frontdoor_turn treats as transient and retries. The decision is not transient: FRONT_DOOR_MODEL is pinned to temperature 0, so the same brief and the same message answer the same citation on every attempt. The person read an error after the whole retry backoff. The answer is worth more than the citation. FrontDoorUnavailable is now raised by the runtime alone, where the vendor may answer the next attempt, and a test reads the service source to hold that.

A delegated agent answers the opposite way, and runtime execution states the rule for both.

⚠️ The prompt is narrower than the check, and the measurement says keep it that way. FRONT_DOOR_INSTRUCTIONS names the [kind id] bracket of ContextPacker._ref_tag as the one citable identifier. The check admits every row of ContextBrief.items, and that list also holds the rows a context body names.

A second bullet that told the model to cite a kind beside an id inside a line halved citation support. Two arms on one trunk: the bracket rule alone reads 1.000, and the wider rule reads 0.500. FDA01 cites a knowledge chunk, and on the wider rule the model wrote kind=agent id=doc-platform-overview. It read a word of the body as a kind. A line holds prose, so "a kind beside an id" has no bound inside one. The bracket gives the model one identifier it cannot misread, and the wider check refuses to spend one turn on a model that misreads it.

⚠️ The prompt is one of four inputs to the recorded evaluations. The instructions, rendered turn, capability catalogue and deterministic application logic each have a fingerprint. A change to the prompt moves the key of test_answer_quality_replay and the number of test_routing_accuracy_eval, so it carries both paid passes. A change to delegate application or a published result schema moves the application member of routing_band_record.trunk_fingerprint() and invalidates the recorded routing band. test_the_checks_need_no_vendor_key holds that member in CI. ENG-2419 recorded 1.000 on every answer-quality metric and 0.8276 routing accuracy, and it measured that ENG-2416's recorded 0.862 does not reproduce on its own trunk.

⚠️ 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 product 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 executor. 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 deploy, while the design rule for an admitted Run is that it keeps the contract 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 a deploy that changes the version 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 also reads the registry itself. CapabilityStarter.start() resolves the executor for a fresh key and pins the version of the running code on the Run. For a replay it reads the existing Run before the registry, so a deploy does not change the 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.

A draft turn

A conversation that names draft_capability_ids runs every turn as a draft turn. Surfaces § Draft conversations owns the route contract. The service changes four things:

  • The shortlist. draft_shortlist() keeps the conversation products and drops the custom half.
  • The instructions. instructions_for(turn) answers DRAFT_INSTRUCTIONS, and build_draft_agent() builds the agent. FRONT_DOOR_INSTRUCTIONS and the default prompt do not change, so the committed recordings still replay. The evaluation cache keys on the instructions the turn read.
  • The prompt. compose_turn_prompt() branches first on turn.drafting. The draft prompt shows the form in plain fields and the plain fields of each product. It shows no published schema and no scoped rows block.
  • The outcome. A delegate becomes TurnResult(kind="drafted", draft=...). The draft is the mapped input of product_input(), never outcome.input. control_run is refused. Nothing calls start_resolved().

The draft set is evals/front_door/draft_cases/. Its scorer reads the TurnResult after apply(), so it measures the model and the mapping together, and it needs no judge.

A draft turn researches

A draft turn helps a person write a search, so it may look things up first. build_draft_agent() takes the tools of the turn, and build_front_door_agent() still takes none.

ToolReadsRight it needs
search_webOne strict Exa search, at most ten pages with highlightsresearch.search_web
fetch_pageOne page as Firecrawl Markdown, cut at 12,000 charactersresearch.fetch_page
search_crm_companiesThe CRM companies of the organizationcrm.search_companies
search_crm_peopleThe CRM people of the organizationcrm.search_people

The code is entry_control/front_door/research.py, and agno.py turns each tool into a framework callable.

  • The tools are projections, and not registry tools. A registry tool runs through ToolInvoker, and the invoker needs a run scope: a run id, a definition and a principal. A turn has none of them. Each body calls the vendor client or the CRM helpers that the registry handler calls. Tools and integrations names this exception.
  • The rights of the person cut the set. FrontDoorService reads PrincipalFactory.held_by(actor) and keeps each tool whose right the person holds. A member holds all four, and a guest holds none. A routing turn reads no right and holds no tool.
  • The organization comes from the actor. No tool argument names one.
  • Each paid call writes one usage row. The row takes the action front_door.turn, a null root run, the vendor cost, and metadata.call set to search_web or fetch_page. charge_rule() charges a chat row only for routing, so the vendor cost counts toward the day ceiling and charges no credits. A call whose row was not written answers that it failed, and the rows it bought are dropped.
  • A fetch reaches only a URL the turn was given. A page or a search result can carry an instruction to read the CRM and fetch a URL with the data in it. Code refuses every URL the model built. A fetch may read any page of a site that the person named, in this message or an earlier user message, or that a CRM company website names, or a subdomain of one. A reply of the chat names no site: an injected page could put a host there for the next turn. It may also read a URL that a search or a fetch of this turn answered, exactly as it stands, so a query string the model added fails.
  • Code bounds the paid calls of one turn. A turn makes at most three web searches and two page fetches. A call past the bound costs nothing and answers that the bound is reached. The instructions ask for the same numbers, but a live turn that read two long pages made six calls, so code holds them.
  • A tool never raises. A vendor fault, a missing key or bad arguments is a JSON answer with ok: false, and the model says what it could not look up.
  • A page is text, never an instruction. The instructions say so.
  • An unclear request asks, and does not assume. When two readings would write two different searches, the model clarifies and offers each candidate as an option. A company name with no website is unclear when the web or the CRM holds more than one company of that name, even when one seems likely. A website, a picked CRM company or an earlier answer settles it. The rule is in the draft instructions alone, so it is model judgement: in live runs "companies like Atlas" and "companies like Mercury" asked which one, and "Mercury (mercury.com)" did not.
  • Each step reports what the tool does. A search reports searching_web, and a CRM read reports searching_crm. A fetch reports opening_page, then reading_page when the page answered and the model reads it. A repeated step sends no second frame. These are the only waiting states that name a tool, and a turn reports one only while that tool runs.
  • A research turn waits 90 seconds, and a timeout answers. The tool loop runs inside the turn.handle step, whose budget is 120 seconds. A timeout answers a fixed sentence and does not raise: a retry would wait again and pay the vendors again. A draft turn with no tool keeps 45 seconds and its raise.
  • A reply that researched says what it found. The model writes one to three sentences in lead, with the CRM company it used and the source URLs. When the turn reported a research state, the reply of a filled form or of a first question round opens with them, before the fixed sentence. A turn that read nothing shows no lead there, as before. Code cleans the lead in three steps. It cuts the text at its first <, because a live model once ended a good lead with a leaked tool tag. It cuts a loop, a fragment that comes three times in a row, because a live model once wrote 's open. forty times. It bounds the lead at 600 characters. After a cut, the lead ends on its last whole sentence.
  • The tools are not strict. The framework would make every argument required. The body validates the arguments against the same model the schema came from.

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.

⚠️ An empty list is dropped before the check and before the start. A published list field holds one item or it is absent: PersonaCriteria writes minItems: 1 on each of its four lists, and the projection renders that as 1 to 20 items. The model reads the bound, writes the one criterion the message named, and writes [] for the others. apply() then refused brief.persona.departments, and the person read a question about a field they never asked for. drop_empty_lists removes every empty list at every depth, and apply() starts the Run from that same mapping (ENG-2464).

⚠️ The coercion is deterministic, and a prompt rule is not. A rule that asks the model to write the shorter form was measured at 3 of 3 on one set of instructions and then lost when ENG-2397 rewrote them. Three further wordings each left seniority empty. This runs on what the model wrote, so no wording decays it, and it covers every list field of every capability rather than one description.

⚠️ It removes no refusal and no meaning. An empty list carries no value, so the key it sits under says nothing its absence does not. A required list reaches the check as a missing field rather than as a short one, and both refuse the input and name the field. Only a list is dropped: null, 0, "" and {} each carry meaning, and icp is string | null, where the null is the branch the contract names.

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.

The application holds each selected product's published output schema. Before it starts a search, it checks every coordinated part of an explicit appended result such as "and their titles and emails" or "with its phone number" against that schema. When any requested part has no matching output field, it asks which part to start and creates no Run. This is an application guard, not a second routing model. A request for fields the search already returns, such as a person's title and Twitter profile, still starts that one capability.

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]      # every eligible product in the fixed vocabulary
    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]
    output_schema: dict[str, Any]
    executor_id: UUID

⚠️ executor_id and output_schema are on the summary, and neither 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 and bounded input projection alone, and tests assert that no executor UUID or raw published schema 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 product records
   ├── DefinitionRepository.list_page(org, kind='agent',    origin='custom', state='active',
   │                                    platform_managed=False)
   └── DefinitionRepository.list_page(org, kind='workflow', origin='custom', state='active',
                                      platform_managed=False)

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 every node of a managed capability from this custom projection.

⚠️ The filter is a boundary and not tidiness. A capability executor and every node of its tree are custom, active rows of the tenant, so the unfiltered custom read returns them beside the product record. The model could then delegate to one 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.

⚠️ The definition rows have two projections, and the boundary covers both. The shortlist is one, and definition_summary in the context brief is the other. Both read DefinitionRepository.list_page with platform_managed=False. ENG-2420: the brief read once passed no filter, so the brief carried the node count, the step labels and the tool names of an executor. The column is on the row, so it marks each node of the tree and not the root alone.

⚠️ The mark replaces an id set, and the set had a ceiling. ENG-2439. The filter first named every released id of every managed node and passed them as one not.in.(). That set only grows, because a released id never leaves it, and one not.in.() travels in the request line where Kong refuses over 8 KB. The read therefore refused an exclusion over 200 ids. It measured 38 on 2026-09-13.

The sync writes the mark on every capability node. ac-backend holds the column and the backfill of the rows that predate it.

⚠️ The mark covers the rows the manifest writes, and not a copy of one. A tenant admin may fork a managed node. The fork is a new row under a new UUID, it carries no mark, and the shortlist offers it. That is the same answer the id set gave, because the fork holds no released id either. A fork is a definition of the tenant: an admin wrote it, and its input reaches no product contract.

⚠️ A standalone managed Agent carries no mark. The sync installs one as a tenant-owned row so the shortlist offers it, and it publishes no contract. It is a capability of the tenant, and it is not a product internal.

Keep the existing ten-custom-definition allowance and include every eligible product in the fixed vocabulary. The two budgets are separate counters, so 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 10,700 tokens. Raw schemas for the product catalogue 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, the bounds a person would need to fill it in, and the short description the schema carries.

⚠️ A closed set of words needs the meaning of each word, and the schema is where that meaning belongs. The projection names the values of an enum and no meaning for any one of them, so a model reads the value by its name alone. sources is the first required field of both search capabilities, and supplied reads as "I was supplied rows": a company-scoped people request picked it, and the schema then demanded the people the request never named (ENG-2416). Each search contract describes sources in one line that names both values and the field each one needs. Keep such a description under 100 characters, which is what the projection carries.

⚠️ A rule in the root allOf reaches no model. The projection walks the root properties, and it walks no root allOf. Both enrich contracts clamp fields to the basic value group while preset is absent or basic, and they state that rule in an allOf. The projection renders the two value groups as a union and no link from a group to preset, so a model wrote fields: ["email"] with no preset and the turn asked about the field the request already named (ENG-2435). Each enrich contract describes fields in one line that names the preset a standard value needs. A cross-field rule the projection cannot show belongs in the description of the field the model writes.

One cap covers one capability, not the section. The fixed product vocabulary bounds the section, and each capability keeps its allocation regardless of what else the tenant installed.

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 costs no depth, and a branch hop does. The V1 shapes publish array of $ref -> oneOf -> object, and a budget that charged for the reference too renders every tagged union of the contract as an unnamed value. Depth alone therefore cannot bound the walk: a schema is a finite tree, so a cycle can only run through a reference, and the walk follows 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.

⚠️ A field two hops below the root loses its tags. company_scope of people.search is oneOf -> object -> array of $ref -> oneOf, one hop deeper than the shape the depth budget reaches, so its four company refs render as unnamed values. The model reads the kind word from the scoped rows block and from company.search, which renders the same union in full.

⚠️ 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 all available product candidates and at most 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='dossier',
            token_budget=...,
            limit=10,
            options={'include_user': False, 'include_documents': True},
        ),
        ContextSourceSpec(name='definition_summary', token_budget=..., limit=20),
        ContextSourceSpec(
            name='knowledge',
            token_budget=...,
            limit=8,
            options={'include_platform': True},
        ),
        ContextSourceSpec(name='memory', token_budget=..., limit=10),
        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.
dossier“What do we sell, and to whom?”Reads the Company dossier of the organization: the company, the services, the ideal customer profiles, the offers and the newest ten documents. It reads no user section.
definition_summary“What visible Agents or Workflows can do this?”Reads safe metadata for visible published definitions. It passes platform_managed=False, so no node of a product is inside it.
knowledge“What do AgencyCore docs say?”Retrieves bounded ready platform chunks.
memory“What did I ask you to remember?”Reads every definition of the organization, because the turn names none. See below.
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 rows the prompt names

⚠️ ContextBrief.text names a row, and it does not always name the id in the word an input takes. ApplicationContextSource renders a label and an attribute, and the id stays on ContextItem.ref. The packer writes the pair as [<kind> <id>] , in the stored kind. A model that read that line alone would write crm.person into an input the schema calls crm_person.

The prompt therefore carries a fourth part, after the context block and before the capability shortlist. It holds one line for each ref the brief does not already name: the kind, the id, and the label when the ref carries one. It holds no other field.

TEXT
Known resource ids, for reference. Use one only where an input field asks for an id. This list is not itself an input.
- kind=crm_person id=33333333-3333-4333-8333-333333333333 label="Ada Lovelace"

⚠️ The brief answers the block for most refs. ContextPacker writes the [<kind> <id>] identifier of each item's subject, and a body that names a row lists that row in ContextItem.body_refs. A row for a ref whose input word is the stored word therefore writes a pair the brief already wrote, on top of a budget the packer already closed. A block of 20 crm.company rows cost 440 tokens and now costs 30. crm.person, intelligence.company and intelligence.person are the three kinds an input spells its own way, so they are the three kinds that still get a row.

The block writes the id as the row stores it, and the packer holds that. _written_as_stored keeps a ref out of ContextBrief.items when _one_line or _no_bracket would rewrite its kind or its id, so no ref the block reads is written two ways.

⚠️ The model therefore reads a run id from the identifier. control_run takes run_id, and the search_result subject of company.search and people.enrich takes one too. The instructions name the [kind id] bracket as a source of an id, and not as a citation device alone. The rule that resolves "this", "that" and "it" reads the context beside the block, because a brief of run rows alone leaves the block with no row.

⚠️ A brief whose every ref it already names writes the header alone, and a line under that header was measured and rejected. The header states a list, and a header with nothing under it reads as a list with no members. Answering that with - none here. Each id is the [kind id] pair on a context line above. scored 82.76% against 86.21% on the same routing band, because FD04 fell to 1 of 3. The line is false: RunHistoryContextSource renders a result ref inside the run body, and no bracket carries it, so a model sent to the brackets alone stops reading the body. Removing the block altogether was measured too, and it cost FD04 its first paraphrase, so the header stays.

⚠️ The header must not read as "you are supplying rows". company.search and people.search both take a sources field whose first value is supplied, and that value means the request carries the rows to work on. A header that read "Rows already in scope" moved the model onto that value for a company-scoped people request, and the closed schema then demands the people the request never named. The routing band measured FD03 at 0 of 3 on that wording.

⚠️ The kind is the word a capability input accepts. This turn writes a capability input, so it renders crm_person and not crm.person. capability_ref_kind owns the map, and the run history body already takes it.

⚠️ A label is free text a person can edit, so it is quoted and escaped. The label is a CRM company name or a person's full name, read again from the product row on every turn. Unquoted, a name written kind=x id=y forges a second pair on the line.

⚠️ One row is one line, and a ref that cannot be written on one is refused. A newline in an id would read as a second row, and a truncated id names a different row, so the ref is dropped and logged rather than rewritten. The label is flattened, and bounded at 64 bytes: a character bound under-reports CJK three to four times, and this block is spent on top of the budget the packer already closed.

The block names at most 20 rows, the number the turn reads the conversation scope with, and a longer list ends with the count it did not name. The cap counts the rows the block writes, so a ref the brief already names takes no slot. An items that holds no nameable ref renders no block at all: an empty section reads to a model as a missing one.

⚠️ A platform ref never appears. A definition, a knowledge chunk and a memory each point at a platform row, and no published capability accepts one as a subject. A line for one offers an id that fails validation. The set no longer protects a row slot, because an input spells each of these kinds the way the row stores it. What it still decides is the block: a brief of platform refs alone writes nothing at all. A definition ref is also an executor UUID, and FD23 states the prompt holds none: a provisioned product executor is a custom active definition, so DefinitionSummaryContextSource returns one beside its own product record.

⚠️ The citation check reads the same list, and it admits both spellings. A model that copies a row out of this block writes the capability word, and the brief stored the other one. Both spellings of a supplied row are allowed, and no other row is. Widening the spelling must never widen the set of rows. The persisted citation carries the stored word, so a reader that resolves a citation by kind reads one vocabulary.

ContextBrief.items holds every row the brief displayed, not the subject of each item alone. A source that renders a row id lists that row on ContextItem.body_refs, and state and knowledge owns that rule. RunHistoryContextSource is the one source that needs it: its subject is the run, and its body names the rows that run produced.

How memory scopes a turn

A memory belongs to the agent that wrote it, so MemoryContextSource filters on subject.definition_id. A turn names no definition. ENG-2440 weighed three answers and took the third rule below.

  1. Give the turn a definition of its own, such as a platform front door definition. It lost. agent.memories.definition_id is NOT NULL and keys agent.definitions, so a row names the definition whose run wrote it. The turn writes no memory, because it holds no Run and no Principal, so that definition would own an empty chain set.
  2. Read memory through a delegated Run alone. It lost. It is the behaviour that made a person repeat a fact chat already held.
  3. Read every definition of the organization when the subject names none. It won. The turn is the person asking inside their own organization.

⚠️ The widening is one filter wide. The organization filter stays, the user chain rule stays (scope = 'organization' or this person's own rows), and the subject-ref guard stays. No Run reaches the wide read: a Principal always names a definition, and ContextSubject.of_actor is the one producer of a null one.

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 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.

⚠️ Most of the states are only visible from inside handle(). checking_context fires before the entity-scope and context reads, finding_capability fires before the capability index and decision read, and the four research states fire inside the decision, from the research tools of a draft turn. 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
searching_web     draft turn only, while a web search runs
opening_page      draft turn only, while a page fetch runs
reading_page      draft turn only, after the fetch answered
searching_crm     draft turn only, while a CRM read runs
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.

Terminal report ownership

Delegation ends the conversational turn after it writes the handoff reply. The Run may finish much later, so the executor does not wait for chat and does not write a conversation row. frontdoor.report closes the loop as a second durable consumer of agent/run.succeeded and agent/run.failed.

TEXT
agent/run.succeeded or agent/run.failed
                    |
                    v
            frontdoor.report
                    |
       read Run result and handoff
                    |
       decide bounded report text
                    |
                    v
   upsert kind=report + message.completed

The function reads the Run with the tenant id from the event. A missing Run, a foreign-tenant Run, a Run without conversation_id or a Run whose handoff reply (kind = reply) is no longer present writes nothing. It then finds the handoff with the same run_id and kind = reply, reads the inbound message through in_reply_to, and reports against the person's question rather than against "I started that."

The report decision is one memoized step and the write is another. The first step checks the organization day ceiling before its model call. A denial makes no call and writes no usage row. A vendor failure or an empty answer is not retried into a second paid call; the deterministic fallback still names whether the work finished or stopped and how many rows it produced. The report is bounded before storage.

The write carries the handoff's in_reply_to, the Run id and kind = report. The unique conversation-message key includes kind, so one inbound message can hold one handoff and one report while a replay converges on the same report row. The ordinary message.completed frame publishes the stored row, so conversation clients need no second reducer. Cancellation is deliberately not a trigger because the control turn already reports that action.

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 budget. A draft turn may make a few bounded reads, see A draft turn researchesAgent / 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.
Selected search cannot return an appended requested detailAsk which part to start. Compare the detail with the selected product's published output schema and create no Run.
Capability disabled after shortlistRunManager.start() resolves again and fails closed.
Capability changed by a deploy after shortlistNo Run starts. The turn pins the contract version of its own registry read, so RunManager finds a version 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 targetsA product capability 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. A draft turn holds four read tools
Platform documentationBounded ready platform chunks from the knowledge source
ProgressSemantic events, then hand off to Run progress. A draft turn adds one per research step

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. A draft turn is the one exception, for a few bounded reads before it writes a search.
  • The model never writes Run state. Structured output is validated before application code acts.
  • An unsupported citation costs the citation, never the turn. A delegated agent answers the opposite way, and runtime execution states the rule for both.
  • Every turn is metered and gated. One usage row per turn for the model call, plus one per paid research call of a draft 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. The whole Front Door context budget is 10,700 tokens, of which the Company dossier takes 1,200.
  4. Can FrontDoorProgressEmitter be deleted? Decided: yes. The durable turn function publishes the progress 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
product search that cannot return an appended detail returns clarify and creates no Run
a product search starts when its output schema already covers the appended detail
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
no node of a managed capability appears in the shortlist or the context brief
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 for its model call, with a null root run id
a draft turn writes one more row for each paid search and fetch
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
an unsupported citation is dropped and recorded, and the turn still answers
a row the brief displayed is citable, whichever of its two words is cited
a kept citation is persisted as the brief's own ref
the service raises FrontDoorUnavailable nowhere, so no refusal is retried

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

The terminal report function owns these:

TEXT
a succeeded Run with a conversation writes one report
a failed Run with a conversation writes one report
a replayed frontdoor.report writes one report row, never two
a missing Run writes nothing
a foreign-tenant Run writes nothing
a Run without conversation_id writes nothing
a Run whose handoff reply is missing writes nothing
a cancelled Run writes nothing
the day ceiling denies the report call and writes no usage row
a vendor failure or empty answer produces the deterministic fallback, not a retry
a report cannot cite a ref the Run did not produce