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 and platform knowledge. User preferences remain deferred with the memory source. |
| 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.
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.
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 per turn that reaches the vendor.
Both reuse what already exists. AccrualChecker is the same component the runtime calls before each segment, it returns a PolicyDecision here exactly as it does there, and ai_usage_log is the same canonical meter. The Front Door adds no counter of its own.
A run has no principal yet at this point, so the check reads the organization day ceiling only. There is no run budget to read. See policy and governance.
A null root run id is the honest record: the spend belongs to a conversation, not to a Run. The organization day total sums both, so one ceiling covers routing and execution together.
Outcome contract
The model shape. CapabilityId is the closed set of five IDs in the capability contract. It lives in src/agentic/shared/capabilities.py.
ResourceRef is the {kind, id, label} pointer to one product row, in
src/agentic/shared/refs.py. State and knowledge
owns it: the same type carries ContextRequest.entities and ContextBrief.items,
which is what makes the citation check below a set membership and not a parse.
class FrontDoorAnswer(BaseModel):
text: str # nonblank
citations: list[ResourceRef] = [] # refs of the supplied brief only
unresolved: list[str] = [] # the facts the brief did not carry
class FrontDoorOutcome(BaseModel):
kind: Literal["answer", "clarify", "delegate", "control_run"]
answer: FrontDoorAnswer | None = None
question: str | None = None
capability_id: CapabilityId | UUID | None = None
input: dict | None = None
run_id: UUID | None = None
action: Literal["cancel"] | None = None
⚠️ An answer carries its evidence, and the service checks it. A citation
naming a ref the context brief did not supply refuses the whole decision, so
the model can neither cite the capability shortlist nor invent a row.
unresolved is where a missing fact goes, rather than a citation that reads as
a source.
⚠️ Every field is optional, so the kind must be made total by a validator.
Read as written, an answer with a null payload parses, and the turn then has a
decision it cannot apply. One model_validator requires the fields of each
kind, and refuses the fields of the other three:
answer answer and nothing else
clarify question and nothing else
delegate capability_id input is optional
control_run run_id and action and nothing else
⚠️ The union order is CapabilityId | UUID, and it is not free. The five
IDs are a Literal, so a UUID string fails that member and parses as the
second. A string that is neither refuses the whole decision, and the turn then
fails rather than answering. That is the correct trade: the model reads the
shortlist in the same call, so an ID outside both formats is a broken vendor
answer and not a routing miss.
Validation rules:
delegate.capability_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
binding. Both were in an earlier draft of this page, and both are redundant
on this path. The shortlist read, the schema the model reads and the executor
the turn starts all come from one registry read of one turn, so there is no
window in which the model could echo a version the turn did not just supply.
A second resolve would cost two more reads on the hottest path of the product,
refuse a start whose input the current schema accepts, and start the new
executor after a mid-turn upgrade, while the design rule for an admitted Run
is that it keeps the binding it was admitted against.
⚠️ A product start still goes through the shared capability start service.
ENG-2329. The turn calls CapabilityStarter.start_resolved() and passes the
capability ID, the contract version, the executor and the input schema of its
one registry read. That entry point reads the registry never, so the rule above
holds. It owns the request digest, the capability-start key namespace and the
version pin, so a chat start and an API start of one product request follow one
replay rule. Before it, the turn called RunManager.start() directly: a chat
start carried no digest and keyed its delivery in the generic definition
namespace, so a redelivered message with edited input replayed the earlier Run
instead of answering idempotency_conflict.
The turn pins the version of its own read, so an executor upgraded between the
shortlist read and the start no longer matches. RunManager refuses that start
rather than running the new executor on input the model wrote for the old
schema. The person reads that the capability changed, and asks again.
The direct start API is the opposite case and reads the registry itself. Its
client reads a contract out of band, caches it, and builds a body against it
hours later, so CapabilityStarter.start() resolves the ID again and compares
the requested version.
Surfaces owns
that route, its contract_version body field and its contract_version_conflict
answer.
control_run.run_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.
One product delegation per turn
The Front Door can select company.search, company.enrich, people.search, people.enrich or signals.search.
It receives eligible IDs, product descriptions and a bounded projection of each input schema. It receives no provider tools, no executor configuration and no executor UUID.
An unknown, unauthorized or unavailable selection cannot start work.
A missing required field produces one concrete clarification and no Run, and two
layers produce it. The prompt asks the model for clarify when it does not hold
a required field, and that is the normal path. apply() then validates the
written input against the same schema, and a failure there becomes one
deterministic clarification naming the failing field paths. It starts no Run.
⚠️ The deterministic clarification names field paths, and that is safe. A field name is the published product contract, which the read API already serves to any caller holding the capability's scopes. It is not executor configuration. Bound the sentence to the first three failing paths, so a wholly wrong input cannot render a wall of text.
A request such as "find companies, enrich them, then find their directors" is a compound plan. Ask which operation to start unless one published capability already covers the whole requested result. Do not silently execute the first step and imply that the remaining steps will follow. A company brief inside People Search is allowed because the published workflow owns that child composition.
Keep answer, clarify, delegate and control_run as the four outcomes.
Cancel remains a deterministic RunManager operation. Live steering and general multi-capability planning remain deferred.
The model does not gain callable run_capability, steer_run or cancel_run tools.
CapabilityIndex
The Front Door discovers product capabilities and custom Agents/Workflows, never individual tools.
class CapabilityIndex:
async def search(self, actor: ActorIdentity) -> Shortlist: ...
@dataclass(frozen=True)
class Shortlist:
products: list[ProductSummary] # at most PRODUCT_LIMIT (5)
custom: list[CapabilitySummary] # at most SHORTLIST_LIMIT (10)
@dataclass(frozen=True)
class CapabilitySummary:
id: UUID
kind: Literal["agent", "workflow"]
name: str
description: str | None
@dataclass(frozen=True)
class ProductSummary:
capability_id: CapabilityId
name: str
description: str
input_schema: dict[str, Any]
executor_id: UUID
⚠️ executor_id is on the summary, and it never reaches the model. The
turn resolved the record already, so a second registry read before the start
would buy nothing and cost two statements. _render() prints the stable ID
alone, and one test asserts that no executor UUID appears in the prompt. It is
the same rule the custom half already keeps for prompts and configuration: the
index carries what the turn needs, and renders what the model may read.
⚠️ Two lists, and not one tagged union. The two halves take different
input rules, so the model must be able to tell them apart: a product start
carries schema-shaped input and no text, and a custom start carries the
message text. A discriminator field on one list would make the model read the
tag before the rule, and a wrong tag would then pick the wrong input rule
silently.
The index combines the product registry with the existing definition reads:
CapabilityIndex
├── CallerRights.held_by(actor): the rights this caller holds now
↓ (no run.start → two empty halves, and the three reads below never run)
├── CapabilityRegistry.list_capabilities(actor): the available records of the five
├── DefinitionRepository.list_page(org, kind='agent', origin='custom', state='active')
└── DefinitionRepository.list_page(org, kind='workflow', origin='custom', state='active')
The last three reads run together. The registry read makes two statements of its own, so one delegating turn costs four reads and two waves.
⚠️ The rights are read before both halves, and one answer decides them. ENG-2341.
CapabilityRegistry applied this rule to the product half alone, so an actor that could
start no run still read every custom Agent and Workflow of the tenant, with names and
descriptions. That is a disclosure gap and not a privilege escalation: RunManager mints
the Principal and applies admission again, so the actor started nothing. A refusal is the
same for every capability, so the branch returns before any read and costs nothing.
⚠️ CapabilityRegistry keeps its own check, and the pair is not redundant.resolve() is a start path and the read routes of surfaces/capabilities reach it, so
both entry points must fail closed on their own.
⚠️ Both readers take one PrincipalFactory. RoleRights holds the 30-second cache,
so the second read costs nothing and the two answers cannot disagree inside one turn. Two
factories over two RoleRights would read the map twice and could split on a role change.
⚠️ A registry fault fails the turn, and it is not swallowed. An empty product half reads to the model as a tenant that installed nothing, so the turn would answer that no published capability can do the work and the person would go and build one. The custom half already fails the turn the same way, so the two halves behave alike.
The custom path makes two definition reads and merges them on (created_at, id) descending.
Exclude product-bound definitions, including inactive historical executors, from this custom projection.
⚠️ The exclusion is a boundary and not tidiness. A provisioned executor
is a custom, active workflow of the tenant, so the unfiltered custom read
returns it beside its own product record. The model could then delegate to it by
UUID, and the UUID path adds the message text and validates nothing. The tenant
would run company.search on an input its closed schema refuses. Filter on the
row carrying capability metadata, not on the active binding: an inactive
historical executor is the same hole.
Keep the existing ten-custom-definition allowance and reserve up to five additional slots for eligible products. The two budgets are separate counters, so five recent custom definitions cannot displace a product and a full product set cannot narrow custom routing.
Rendering a schema
⚠️ A published input schema may be 32,768 bytes, and the whole Front Door context budget is 8,500 tokens. Five raw schemas can exceed the budget of the turn that reads them. The prompt therefore renders a compact projection and never the raw JSON Schema: for each root property, the name, whether it is required, the type, any enum values, and the bounds a person would need to fill it in.
One cap covers one capability, not the section. The vocabulary holds five stable IDs, so the section is bounded by five times that number, and a capability renders the same whatever else the tenant installed. A cap over the section would let the first capability spend what the fifth needs.
Over the cap, keep every required field, then take the optional fields in declared order until the next one would not fit. Render the kept fields in declared order: a reordered list reads to the model as a different contract.
⚠️ A required field is never dropped. Truncating one leaves the model writing input that fails validation on every attempt, and the person then reads a clarification for a field the prompt never showed. If the required fields alone exceed the cap, drop the whole capability from the shortlist and log it: a capability that cannot be described cannot be routed to.
⚠️ A reference hop and a branch hop cost no depth, so depth cannot bound the
walk. The V1 shapes publish array of $ref -> oneOf -> object, and a budget
that charged for all three renders every tagged union of the contract as an
unnamed value. A schema is a finite tree, so a cycle can only run through a
reference: follow each reference at most once on one path. Pydantic emits a
self-referencing $defs union for a recursive discriminated union, so it is a
schema a tenant can publish.
⚠️ origin is custom, and the default union is wrong here. list_page
with no origin also returns the platform templates that are active.
DefinitionResolver filters on organization_id alone, so a template resolves
for no tenant. A shortlisted template is therefore a delegate that always
answers definition_not_found. A template is a fork source, and it is not a
capability.
⚠️ description is a new optional key of the agent config and of the workflow
config. AGENT_FIELDS and WORKFLOW_FIELDS are closed sets, and read_keys
refuses an unknown key, so the field must be declared before an author can write
one. The skill validator already carries the same key. Definition holds the
name alone, so the index reads the description out of published_config.
There is no AgentDefinitionRepository and no WorkflowDefinitionRepository. One repository shape serves agents, workflows and skills. See runtime definitions.
Rules:
- Return only published and enabled capabilities discoverable by the organization/principal.
- Draft Specs never appear in the index.
- Return routing summaries and product input schemas only: no prompts, skills, Tool Registry schemas, or complete executor definitions.
- Policy admission remains authoritative; capability visibility is not authorization.
- Product lookup uses stable IDs. Custom discovery retains its bounded newest-first definition read.
- Add a lexical filter on the day a catalogue outgrows one page, and embeddings only after that.
- Keep at most five product and ten custom candidates, on two separate counters.
- Render a compact schema projection under one byte cap per capability. Never render a raw published schema, and never render an executor UUID.
Front Door context
Front Door context is intentionally smaller than execution context. It is not a second builder. The Front Door calls the shared ContextBuilder from state and knowledge with one fixed platform policy.
FRONT_DOOR_CONTEXT_POLICY = ContextPolicy(
total_token_budget=...,
refresh_on_resume=False,
sources=[
ContextSourceSpec(name='conversation', token_budget=..., limit=20),
ContextSourceSpec(name='application', token_budget=..., limit=20),
ContextSourceSpec(name='definition_summary', token_budget=..., limit=20),
ContextSourceSpec(
name='knowledge',
token_budget=...,
limit=8,
options={'include_platform': True},
),
ContextSourceSpec(name='run_history', token_budget=..., limit=5),
],
)
entity_refs = await conversation_scope.list_entity_refs(
conversation.id,
actor.organization_id,
limit=20,
)
context = await context_builder.build(
ContextRequest(
subject=ContextSubject.of_actor(actor),
query=message.text,
entities=entity_refs,
conversation_id=conversation.id,
),
FRONT_DOOR_CONTEXT_POLICY,
)
One builder means one budget model, one failure model and one set of tenancy tests. A private Front Door builder would need all three again.
The sources the current slice enables
| 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. |
definition_summary | “What visible Agents or Workflows can do this?” | Reads safe metadata for visible published definitions. |
knowledge | “What do AgencyCore docs say?” | Retrieves bounded ready platform chunks. |
run_history | “Is my company search still running?” | Built in Phase 3. |
Entity scope is durable pointer state
⚠️ The conversation scope stores refs, not facts. agent.conversation_entities records conversation-scoped ResourceRef pointers with a label, the message that introduced them and the last reference time. The row is a convenience index. The current body still comes from the product reader that owns the ref kind.
ApplicationContextSource resolves request.entities and never searches request.query. A malformed, unsupported, stale or foreign ref drops before it reaches model context. If exactly one readable company ref fits “this company”, the answer can use it. If more than one fits, the Front Door asks which one instead of guessing.
The source that is deferred, and why
⚠️ memory cannot scope a Front Door turn. MemoryContextSource filters on subject.definition_id, because a memory belongs to the agent that wrote it. A turn names no definition, so the source has nothing to filter by and returns nothing. Widening memory to a definition-less read is a change to a Phase 3 component and its precedence tests, so it is its own ticket.
The policy allows control context and already-scoped context. It enables no exploratory CRM, email, web or research source. Those are task work, and task work is delegated.
Shared Run start contract
Front Door and Triggers use the same runtime boundary. Runtime execution owns the shape of StartRunCommand, and the Front Door must not create a second start contract.
The Front Door passes the actor, not a minted Principal. RunManager mints the Principal, because the intersection needs the definition and the Run ID. The idempotency_key the Front Door supplies protects retries or duplicate inbound delivery from creating duplicate Runs.
A product start reaches that boundary through CapabilityStarter.start_resolved(), which is the shared capability start service and not a second start contract. It namespaces the delivery key, builds the request digest and passes one StartRunCommand. A custom definition start calls RunManager.start() directly and keeps the generic delivery key.
Delegate and cancel
Delegate
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 two phases that happen inside it. A callback is a parameter with a no-op default. A protocol would be a component, an injected seam and a fake, on the hottest path of the platform, for one call.
⚠️ Two of the four states are only visible from inside handle(). checking_context fires before the entity-scope and context reads, and finding_capability fires before the capability index and decision read. A turn function that wrapped handle() from outside could publish thinking and preparing_task and nothing between them, which is the whole span the person is waiting through. That is why the callback exists and the component does not.
The turn publishes a very small semantic vocabulary while the Front Door owns the work:
thinking
checking_context
finding_capability
preparing_task
The Gateway decides how to render or drop these events for each channel. They are semantic UI state, not chain-of-thought.
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.
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 | 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. |
| Capability disabled after shortlist | RunManager.start() resolves again and fails closed. |
| Capability upgraded after shortlist | No Run starts. The turn pins the contract version of its own registry read, so RunManager finds a binding that no longer matches and answers capability_unavailable. The person reads that the capability changed, and the next turn reads the new contract. |
| Ambiguous Run 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 | One of five product capabilities 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 |
| Platform documentation | Bounded ready platform chunks from the knowledge source |
| Progress | Four semantic events, then hand off to Run progress |
Rules
- It plans dispatch, not execution. It chooses one Agent or Workflow and supplies the input.
- One model call; no routing model before it. Capability search is deterministic retrieval, not another agentic hop.
- It selects a capability, never a tool. The selected Agent owns tool selection through its definition.
- It selects a product by its stable ID, never by its executor UUID. The executor UUID is turn state; the stable ID is the product contract.
- One agentic hop. A delegated Agent may create child Runs through the runtime, but it never calls another Front Door.
- Every delegation goes through
RunManager. There is no private synchronous execution path. - Ask before dispatch when required input is missing. One clarification is cheaper than a failed Run.
- Policy owns approval. The Front Door contains no rules such as “500 emails requires approval”.
- Platform questions are knowledge questions. Relevant AgencyCore documentation may be answered directly from retrieved platform context. When retrieval returns no relevant chunk, the Front Door says the available docs do not contain enough information.
- Fresh business exploration is delegated. CRM searches, email searches, web research, and external-system work belong to runtime Agents/Workflows.
- The model never writes Run state. Structured output is validated before application code acts.
- Every turn is metered and gated. One usage row per turn, and one accrual check before the model call.
- Agno remains a reasoning implementation detail. Product Run state, policy, context ownership, and execution boundaries belong to AgencyCore.
It runs on Agno in-process and must remain fast. It needs no sandbox, MCP server, skill execution, workflow engine, durable agent session, or tool catalogue of its own.
Open decisions
- 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. This keeps the whole Front Door context budget at 8,500 tokens.CanDecided: yes. The durable turn function publishes the four 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
a product start passes the executor uuid the shortlist resolved
compound requests never create an implicit multi-start plan
product shortlist entries cannot be displaced by recent custom definitions
a product-bound definition never appears in the custom shortlist
the prompt holds no executor uuid and no raw published schema
a required schema field is never dropped by the byte cap
StartRunCommand carries source + conversation + idempotency key
the index returns no skill, no draft, and no platform template
policy denial cannot be bypassed by Front Door
a require_approval admission becomes waiting Run state
a disabled capability answers definition_not_published, and the turn reports it
control_run refuses a Run of another organization
ambiguous Run control returns clarify
cancel is idempotent
Front Door never receives the tool registry
a turn that reaches the vendor writes one usage row, with a null root run id
the usage row carries the conversation id in metadata
a turn over the organization day ceiling makes no model call, and writes no row
a memoized replay makes no vendor call and writes no second usage row
the turn holds an actor, and mints no Principal
The turn function owns three more, because it owns the durable step and the conversation write:
progress stops after Run ownership begins
a retried turn makes one model call
a retried turn writes one assistant message, not two