Front door
The conversational control layer. It turns a request into one structured decision, then deterministic application code answers or hands work to RunManager.
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.
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.
| Step | Job |
|---|---|
| Context | Read recent conversation, conversation summary, active Runs, already-scoped entities, definition summaries, platform knowledge and durable memory. |
| Capability shortlist | Read eligible product capabilities and published custom Agents/Workflows for this actor. |
| Decision | One Agno call decides whether to answer, clarify, delegate, or control a live Run. |
| Handoff | Deterministic application code validates the decision and calls RunManager when execution is required. |
FrontDoorOutcome
answer(text)
clarify(question)
delegate(capability_id, input)
control_run(run_id, action)
| Outcome | Effect |
|---|---|
answer | Reply using context already supplied to the Front Door. |
clarify | Ask one question because required input or a Run reference is ambiguous. |
delegate | Validate the selected capability. A product start calls CapabilityStarter.start_resolved(); a custom definition start calls RunManager.start(). |
control_run | Validate 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
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.
| Component | Responsibility |
|---|---|
FrontDoorService | Own one conversational turn and apply the structured outcome. |
FrontDoorRuntime | The neutral reasoning port. One method, decide. |
AgnoFrontDoorRuntime | The one implementation. It holds every Agno import. |
FrontDoorOutcome | Closed structured decision contract. |
FRONT_DOOR_CONTEXT_POLICY | The fixed ContextPolicy that keeps the turn to lightweight control and already-scoped context. |
CapabilityIndex | Find a small shortlist of published Agents/Workflows. |
CapabilitySummary | Minimal routing DTO exposed to the model. |
RunManager | Product-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.
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:
@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.
| Arm | What changed | Routing | Answer outcome | Citation support |
|---|---|---|---|---|
| control | the trunk | 31/35 | 0.8678 | 0.9028 |
| A | named blocks, no rule added or cut | 29/35 | — | — |
| A2 | A, two rules back beside delegate | 31/35 | — | — |
| D | the routing matrix rows added | 31/35 | 0.933 | 0.809 |
| E | D, the citation contract back beside answer | 31/35 | 0.925 | 0.937 |
| F | E, nine defects of the text repaired | 31/35 | 0.9256 | 0.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.
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.
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.
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:
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_idmust be one of the candidates supplied to the model.RunManagerresolves 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
textfield. - 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_idmust resolve to a Run of the actor's organization.RunRepository.get(run_id, organization_id)answers it, exactly as_shared/org_scope.require_rundoes.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, andRunManager.start()answersduplicatewith 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)answersDRAFT_INSTRUCTIONS, andbuild_draft_agent()builds the agent.FRONT_DOOR_INSTRUCTIONSand 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 onturn.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
delegatebecomesTurnResult(kind="drafted", draft=...). The draft is the mapped input ofproduct_input(), neveroutcome.input.control_runis refused. Nothing callsstart_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.
| Tool | Reads | Right it needs |
|---|---|---|
search_web | One strict Exa search, at most ten pages with highlights | research.search_web |
fetch_page | One page as Firecrawl Markdown, cut at 12,000 characters | research.fetch_page |
search_crm_companies | The CRM companies of the organization | crm.search_companies |
search_crm_people | The CRM people of the organization | crm.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.
FrontDoorServicereadsPrincipalFactory.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, andmetadata.callset tosearch_weborfetch_page.charge_rule()charges a chat row only forrouting, 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 reportssearching_crm. A fetch reportsopening_page, thenreading_pagewhen 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.handlestep, 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.
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)
@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:
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.
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
| Source | Answers | State |
|---|---|---|
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.
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.
- Give the turn a definition of its own, such as a platform front door definition. It lost.
agent.memories.definition_idis NOT NULL and keysagent.definitions, so a row names the definition whose run wrote it. The turn writes no memory, because it holds no Run and noPrincipal, so that definition would own an empty chain set. - Read memory through a delegated Run alone. It lost. It is the behaviour that made a person repeat a fact chat already held.
- 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
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
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:
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.
Front Door progress ---- delegation ----> Run progress
owner owner
Front Door Runtime/Run
When delegation begins:
RunManager.start()answersstarted,duplicate, or a refusal. Aduplicateis 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 nopreparing_task, because the work is already under way and a second progress line would double-render it.StartRunResult.outcomeis what says which case it was, so the turn reads it and the service returns it.- If
RunManager.start()answersstartedwith a non-terminal Run, the turn publishespreparing_task. - Once a Run owns the work, the turn publishes no more task progress.
- 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.
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 not | Because | Owner |
|---|---|---|
| Run long work | Delegated work outlives the conversational turn | Runtime / Run |
| Do deep research or exploratory business reads | That requires task tools and a budget. A draft turn may make a few bounded reads, see A draft turn researches | Agent / Workflow |
| Manage individual tools | Front Door selects capabilities; Agents select tools | Agent definition / tool layer |
| Decide approval conditions | Governance must behave consistently on every path | Policy plane |
| Step through or mutate a Workflow | Workflow coordination is deterministic execution | Workflow executor |
| Trust model-selected IDs blindly | Model output is not authorization | deterministic validation + RunManager |
| Insert Run rows or call Inngest | That creates a second lifecycle path | RunManager |
Each forbidden responsibility is a path toward turning the Front Door into a second runtime.
Failure handling
| Failure | Behaviour |
|---|---|
| No suitable capability | Clarify 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 id | Reject deterministically; never start a Run. An ID in neither format refuses the decision itself, and the turn fails. |
| Product input fails its schema | One deterministic clarification naming the first three failing paths. No Run. |
| Selected search cannot return an appended requested detail | Ask which part to start. Compare the detail with the selected product's published output schema and create no Run. |
| Capability disabled after shortlist | RunManager.start() resolves again and fails closed. |
| Capability changed by a deploy after shortlist | No 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 reference | Ask one clarification question. |
| Admission denied | Return 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 approval | Return/stream the waiting state owned by the Run; approval machinery owns resolution. |
| Front Door model failure | Fail the turn cleanly; do not create a Run from partial output. |
| Duplicate inbound request | Reuse 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 turn | The 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 written | The 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
| Decision | V1 choice |
|---|---|
| Front Door framework | Agno, in-process, behind FrontDoorRuntime |
| Model hops | One |
| Routing | Deterministic capability shortlist + one structured model decision |
| Executable targets | A product capability by stable ID, or a published custom Agent/Workflow by UUID |
| Product input | Validated against the published schema of the same turn's registry read. No version echo, no second resolve |
| Execution boundary | RunManager |
| Approval owner | Policy + approval service; Runtime owns wait/resume |
| Run control | Idempotent cancel only. Steering is V2 |
| Capability search | The newest active capabilities of the organization, capped. No query in V1 |
| Action tools on Front Door | None. A draft turn holds four read tools |
| Platform documentation | Bounded ready platform chunks from the knowledge source |
| Progress | Semantic 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
- Should the Front Door use an Agno session, or remain stateless over AgencyCore-owned conversation state? Decide with a latency benchmark.
- At what catalogue size does lexical capability search stop being good enough?
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.CanDecided: yes. The durable turn function publishes the progress events. See Progress ownership.FrontDoorProgressEmitterbe deleted?Where does a conversation store its entity scope?Decided:agent.conversation_entities. The turn passes the current scoped refs toContextRequest.entities. See Entity scope is durable pointer state.
Minimum contract tests
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:
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:
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