Platform contract

One platform behind chat, interactive channels, triggers, approvals and background runs, with one Agno runtime, one tool layer, one state layer, and three cross-cutting planes.

1 min read Updated Sep 2, 2026

Platform contract

This page is the contract behind the agentic platform overview. Read the overview first for the one-page diagram and the whole path from a request to a result.

The platform has one run boundary. Two paths reach it today, and the invariant worth defending is the boundary, not the count.

An outside AI agent is a third caller. It works today only by driving the ac CLI on a user's seat, so it enters through the normal product API. An MCP server is not built and not designed. See agent access.

TEXT
Person / interactive channel                 Event / schedule / webhook
            │                                            │
            ▼                                            ▼
     Channel Gateway                                  Triggers
            │                                            │
            ▼                                            │
       Front Door                                        │
            └──────────────┬─────────────────────────────┘
                           ▼
                   StartRunCommand
                           │
                           ▼
                       RunManager
                           │
                    Policy admission
                           │
                           ▼
                         Inngest
                           │
                  ┌────────┴────────┐
                  ▼                 ▼
            Agent execution   Workflow execution
                  │                 │
                  └────────┬────────┘
                           ▼
                         Tools
                           │
                           ▼
                 services / vendors / MCP

          Context / State             Policy
                ▲                  admission/action/accrual
                │
             Runtime                 Observability
                                      spans + usage

The newest V1 decision is deliberately simple:

Agno is the only agent runtime. Inngest owns durable coordination. AgencyCore owns product state, definitions, tools, policy, context, approvals and observability.

What we build

NeedOwner
Conversational routing to role-specific Agents/WorkflowsFront Door
Interactive chat transport: web chat in the current V1 slice. Slack, Telegram and WhatsApp laterChannel Gateway
Email receive/sendNylas producer + email tools, not Channel Gateway
Event/schedule initiated workTriggers
Routing one event to a new Run and to a waiting RunEventRouter
Authoring triggers, policies and connectionsThe three admin surfaces
Long-running/retryable executionRunManager + Inngest
Agent model loopAgno through AgentRuntime
Deterministic workflowsWorkflow executor over Inngest primitives
Internal/vendor actions now. MCP actions laterTools & Integrations
Human approvalPolicy decision + agent.approvals + Inngest wait
Context, memory, knowledge, CRM readsState & Knowledge
Duplicate command protectionIdempotency
Auditable execution recordRuns + agent.spans + usage meter

Each new feature should normally become data + a surface/adapter, not a new infrastructure subsystem.

Product capability contracts

Phase 7 exposes five stable product IDs: company.search, company.enrich, people.search, people.enrich and signals.search. The Company, People and Signals contracts own their schemas, permissions, references, source coverage and enrichment presets. The design scenarios define the expected normal and failure behavior.

A capability names a business action. A published tenant Workflow executes it. The registry resolves a stable ID to that Workflow's UUID. Every caller still enters through RunManager. A Tool remains a technical operation. A capability adds neither a runtime nor a workflow node type.

Search and Enrich are separate operations. Search returns stable references; Enrich writes canonical Intelligence. The product surface carries selected refs between them. CRM and list writes remain explicit actions. The Front Door delegates once per turn. Published workflows may compose child workflows under the existing grant, snapshot and cost rules.

Core principles

  1. One product execution substrate. One Run model, one status model, one event surface.
  2. One agent framework in V1. Agno runs reasoning loops; there is no backend router or managed-agent backend in the target architecture.
  3. Definitions are data. Agent, Workflow and Skill definitions live in the database.
  4. Publishing is a safety boundary, not a revision system. Draft → validate → current published configuration. Runs freeze snapshots.
  5. One agentic hop. The Front Door selects one Agent or Workflow. Deterministic workflow coordination is not another reasoning hop.
  6. An Agent reaches the world only through a Tool. No credentials, direct repositories, raw SQL or arbitrary API access in model context.
  7. A Skill teaches; a Tool acts; a Workflow coordinates. Do not collapse the three.
  8. The channel is a rendering detail. Agents do not learn Slack/Telegram/WhatsApp-specific behavior.
  9. Email is not an interactive channel primitive. Nylas owns email transport; email enters through events and tools.
  10. Each fact has one owner. Authoritative application/CRM data > published knowledge > memory.
  11. Policy is one plane. Check permission/conditions at admission, action and accrual.
  12. Policy changes are live, and so is the grant. An in-flight Run reads the current rule set at its next checkpoint, and re-intersects its principal against the actor's current rights. Authority can shrink mid Run; it never grows.
  13. Observability is one plane. Runs/spans/usage are the durable record; SSE is transient delivery.
  14. Idempotency protects effects, not orchestration. Inngest retries safely only when business commands and external effects are idempotent.
  15. Keep V1 boring. No general plugin framework, custom policy language, custom scheduler, custom durable checkpointer, or deep agent hierarchy.

Human request flow

  1. Channel Gateway verifies and normalizes an interactive message.
  2. A verified identity becomes an actor identity; RunManager mints the Principal. An unknown identity starts no Run.
  3. Front Door builds lightweight context and queries the CapabilityIndex.
  4. One Agno call returns answer, clarify, delegate, or control_run.
  5. delegate becomes a shared StartRunCommand and calls RunManager.start().
  6. RunManager freezes the published definition, skills, tool contract, context policy and principal grant into the Run snapshot.
  7. Policy admission returns allow, deny, or require_approval.
  8. Allowed Runs dispatch through Inngest; approval Runs wait on the same Run row.
  9. Runtime rebuilds fresh authorized context and executes an Agent or Workflow.
  10. Every Agent tool call returns through ToolInvoker and action policy.
  11. Spans and usage are written durably; live events are published for active surfaces.
  12. Gateway renders semantic output for the originating interactive channel.

Machine request flow

TEXT
producer -> PlatformEvent -> EventRouter -> TriggerMatcher -> TriggerDispatchService
         -> StartRunCommand -> RunManager -> Policy -> Inngest -> Runtime

Triggers never bypass the normal Run boundary. Their service principal constrains context and tools just as a user principal does for an interactive Run.

Layer ownership

TierPageOwnsMust not own
MapOverviewThe one-page map of the whole platformAny detail a layer page owns
InterfacesSurfacesDirect product APIs/UI contractsRuntime logic
InterfacesChannel gatewayInteractive transport, identity/session mapping, renderingEmail transport, prompts, business logic
InterfacesFront doorConversation intelligence and capability selectionTask execution, approval rules, tool management
InterfacesTriggersEvent normalization/matching and trigger dispatchPolicy, workflow execution, scheduler implementation
InterfacesAgent access (CLI and MCP)How an outside AI agent reaches the platform. Stub, not a designEverything below the CLI it drives
RuntimeAgentic runtimeRuntime architecture and execution boundariesTool implementation, domain state
RuntimeRuntime definitionsDraft/publish/validate/snapshotExecution state
RuntimeRuntime executionRun lifecycle, execution, waits, cancellation, live eventsDefinition authoring
ServicesTools and integrationsModel-facing action boundary and integrationsAgent reasoning
ServicesState and knowledgeAuthorized context and memory/knowledge ownershipAgent-selected actions
PlanesPolicy and governancePermission, conditions, approval decisions, limitsTenancy boundary, model reasoning
PlanesObservability and operationsDurable execution record and usageBusiness control flow
PlanesIdempotencyDuplicate business command/effect guardApproval or workflow state

Shared contracts

TEXT
NormalizedMessage     Channel Gateway -> Front Door
FrontDoorOutcome      Agno Front Door -> deterministic application code
PlatformEvent         Producers -> EventRouter -> TriggerMatcher and Inngest
StartRunCommand       Front Door / Triggers / API -> RunManager
StartRunResult        RunManager -> every start caller; a closed outcome set
Principal             Admission -> every action and context read
AgentExecutionRequest AgentExecutor -> AgentRuntime
AgentExecutionResult  AgentRuntime -> AgentExecutor
ExecutionOutcome      any executor -> RunManager
RunResult             the durable product payload on the Run row
ToolSpec/ToolResult   Runtime <-> Tool layer
ToolInvocation        Runtime -> Tool handler; the ambient identity of one call
ContextBrief          ContextBuilder -> AgentRuntime
bound()               the one payload boundary; three payloads, three layers
PolicyDecision        Policy -> RunManager / ToolInvoker / accrual caller
RunEvent              Runtime/recorders -> SSE/Gateway
ResourceRef           everywhere a payload points at a row instead of carrying it

ResourceRef

It is the most shared type in the platform, so it is defined here and nowhere else.

PYTHON
@dataclass(frozen=True)
class ResourceRef:
    kind: str          # 'crm.company', 'prospect', 'knowledge_source', 'run'
    id: str
    label: str | None = None    # a human readable name, for a UI that must not join

It appears in RunResult.refs, ContextRequest.entities, ContextItem.ref, ContextBrief.items, a truncated ToolResult, and a stored idempotency response that was too large to keep.

Three rules keep it honest.

  • A ref is a pointer, never a copy. label exists so a list renders without a join, and it may be stale. Anything that must be current is re-read through the owning domain.
  • kind is a registered string. The owning resolver applies the store's tenant guard. Public service-key reads use scoped_db. Agent-schema reads filter organization_id explicitly.
  • A ref that does not resolve for a principal is dropped, not shown. That is what stops a memory row naming a company from leaking its existence.

The payload boundary

Three payloads are bounded, in three different layers, by one algorithm. It is defined here for the same reason ResourceRef is: written three times it becomes three algorithms, and the one that gets it wrong is silent.

PYTHON
Payload = dict | list

def bound(payload: Payload, limit_bytes: int) -> tuple[Payload, bool]:
    """Return the payload within the limit, and whether anything changed."""

A top level list is a payload too, and it is the common one. ToolResult.data is typed Any, and a read tool answers with rows: crm.search returns a list of people. A signature that took a dict alone would force every caller to wrap, or to write the second implementation this function exists to prevent. Both shapes have top level items, and the algorithm below is the same for each. The return keeps the shape it was given, so a list never becomes an object.

CallerPayloadLimit
RunManagerRunResult.output32 KB
ToolInvokerToolResult.output32 KB
SpanRecorderspan input and span output8 KB

Two payloads on agent.runs are refused rather than bounded, and both for one reason: a drop there is silent and it changes what the Run does.

The snapshot is the first. Dropping a top level item removes the frozen tool contracts or the rendered skill text, and the Run then executes an agent that silently lost a tool it was published with. Nothing raises and no reader can tell. Validation refuses an oversized snapshot at publish, and the freeze fails the start. See runtime definitions.

The Run's input is the second, and it took the opposite answer until 2026-08-21. A trimmed input is the caller's own instructions with the tail removed: the agent acts on part of a request and reports success, and RunResult.truncated describes the output rather than the input, so no field on the row records the loss. The rule this page already gives callers is the answer: a large payload passes a ResourceRef and leaves the body in the product table that owns it. So RunManager refuses the start with input_too_large instead of trimming it. Bounding it protected agent.runs from a 5 MB trigger payload; refusing it protects the same table more strictly, and it protects the run as well.

It drops whole items. It never cuts a structure.

TEXT
measure each top level item once, and count the envelope with them
      │
      ├─ the whole payload fits  -> return it unchanged, truncated = false
      │
      ├─ ANY item is over the limit ON ITS OWN
      │      -> replace EVERY such item FIRST, before any drop
      │      -> the whole value becomes one Dropped marker
      │
      └─ still over -> drop whole top level items from the tail
                       until the running total fits, then serialize once to confirm

Every oversized item is replaced, not the first one. Take {'a': 40 KB, 'b': 40 KB} at 32 KB. Replace one and the second is still over the limit on its own, so the tail drop removes it whole and the caller loses a field it could have had a marker for. Both are replaced in the same pass, and the marker records what each one measured.

The replacement is the whole value, and the function never looks inside it. A 40 KB rows becomes one Dropped marker. bound() reads one level, because a function that recurses has to decide how deep to go and every caller would get a different answer to that. A caller that wants its large field summarized rather than marked builds the summary before it calls.

An oversized item is replaced before anything is dropped, because otherwise the order of the keys decides the answer. Take {'rows': 40 KB, 'summary': 1 KB, 'counts': 1 KB}. Drop from the tail first and summary and counts go, rows alone is still over, and the caller keeps the one item it could not have used. Replace rows first and all three survive, two of them whole. A dict keeps insertion order, and no caller chooses that order, so a rule that reads the tail first returns a different payload for the same data.

Each item is measured once, not once per drop. Re-serializing the whole payload after every drop is quadratic, and the case this function exists for is a read tool that answers with two thousand rows. Serialize each top level value once, keep a running total, drop from the tail against that total, and serialize the whole result once at the end to confirm the limit. One confirmation, not one per item.

The running total counts the envelope, or the confirmation fails on a wide payload. The keys, the quotes, the commas and the braces are bytes the reader receives, and a dict of ten thousand short keys carries more of them than of values. Add each key and its separators to the total beside its value. The final serialize then confirms a limit the total already respected, and it never has to drop a second time. If it does exceed anyway, drop from the tail again and re-confirm: the loop is bounded by the item count, and reaching it means the envelope accounting is wrong.

The second return value is true when anything changed, a replacement as much as a drop. A 40 KB output reduced to one marker is not intact, and a flag that read false there would tell every reader it was. The caller sets its own field from it: RunResult.truncated, ToolResult.meta.truncated with meta.count kept honest, or span.attributes.truncated.

A payload that is neither a dict nor a list is a caller error. bound() raises TypeError rather than wrapping it. Wrapping would change the shape the caller stores, which is the one promise this function makes.

bound() writes a marker, never a ResourceRef. A ResourceRef names a row: it carries a kind and an id, and it must resolve through the owning domain. bound() is a pure function over a payload, and it has no row to point at. RunResult.output is free JSON, so there is often no row at all.

PYTHON
@dataclass(frozen=True)
class Dropped:
    reason: Literal['too_large']
    bytes: int                    # what the field measured before it was replaced

It serializes under a sentinel key, because the payload it sits in is free JSON.

JSON
{"__dropped__": {"reason": "too_large", "bytes": 40960}}

A bare {"reason": "too_large", "bytes": 40960} is a body a real tool could return, so a reader could not tell a marker from data. __dropped__ is a key no caller writes, and one lookup finds it. The marker keeps the shape and the size honest, and it parses. A caller that does own the row substitutes a ResourceRef for the marker afterwards: ToolInvoker does this, because a tool knows the rows it read. RunManager does not, so a Run keeps the marker. One function, one rule, and the ref stays with the layer that can resolve it.

Serialize with the same encoder the write uses. A payload holds UUID and datetime values that plain json.dumps refuses, and an encoder that differs from the one the database client uses measures a different number of bytes than the reader receives.

A truncated JSON body is worse than a short one, and that is the whole reason this is a function rather than a convention. json.dumps(payload)[:32768] satisfies the limit and produces a body no reader can parse. It looks correct in review and it fails at the reader, which is late.

Two rules travel with it.

  • Measure the serialized bytes, not the object. The limit is what a reader receives.

A second size limit sits beside this one and is easy to meet by accident: a PostgREST filter travels in the URL, and Kong refuses a request line over 8 KB. One in.() list therefore holds about 220 UUIDs. Any set that grows with a Run tree is filtered by a denormalized root_run_id instead, which is why agent.spans and agent.approvals both carry that column, and why ai_usage_log is stamped with it. See invariant 38.

  • Redaction runs before bounding, never after. Bounding drops items; a credential in a dropped item would be removed by luck rather than by rule. The tool layer owns the redaction step, and observability relies on it having already run.

RunSource

PYTHON
@dataclass(frozen=True)
class RunSource:
    kind: Literal['front_door', 'trigger', 'api', 'workflow_step']
    trigger_id: UUID | None = None
    parent_span_id: UUID | None = None    # set for workflow_step

    @classmethod
    def front_door(cls) -> 'RunSource': ...
    @classmethod
    def trigger(cls, trigger_id: UUID) -> 'RunSource': ...

The Inngest lane follows the actor kind, not RunSource alone. An interactive user actor on front_door or api is interactive; a machine actor and every workflow_step child are batch. See runtime execution.

Naming rules

Three words are load bearing across every page, and each has exactly one meaning.

WordMeansNever means
contextwhat a model may know: ContextBrief, ContextPolicy, ContextSourceambient execution identity, which has accessors and no noun
stateconfiguration lifecycle: a definition is draft, active or disabledexecution lifecycle
statusexecution lifecycle: a Run is queuedcancelled; a span is running, ok or errorconfiguration lifecycle

Two more, so a grep returns one subsystem.

  • scope belongs to policy. It is one exact action name in Principal.scopes. Nothing else is called a scope.
  • Event is never a bare type name. The platform envelope is PlatformEvent, and the qualified siblings are InboundEvent, RunEvent, ConversationEvent and InngestEvent.

Product state vs infrastructure state

AgencyCore product state is stable and readable by the product:

TEXT
Run status:     queued | running | waiting | succeeded | failed | cancelled
Run waiting_on: approval | event | delay      null unless status is waiting

Inngest attempt IDs, worker retries, queue state and Connect transport state are infrastructure telemetry. Keep them as correlation fields, not the product lifecycle API.

Definitions and Run snapshot

Definitions use:

TEXT
Draft config -> validate -> published config

There is no definition revision/history subsystem in V1. Before dispatch the Run freezes the effective execution snapshot:

TEXT
Frozen in Run.snapshot
  published definition config
  rendered Skill content
  model-facing Tool contracts
  ContextPolicy
  model configuration

Frozen on the Run row, in its own column
  principal grant           agent.runs.principal

Read live at checkpoints/execution
  Tool enabled/disabled kill switch
  Policy rules
  the actor's current rights, narrowing the frozen grant
  credentials / connection status
  handler implementation
  business data and retrieved context

This keeps work stable while allowing emergency policy/tool revocation to take effect immediately.

The principal grant is frozen too, and it is not inside snapshot. agent.runs.principal is its own column and PrincipalFactory is its one writer. One fact with two homes disagrees with itself the first time either writer changes.

A disabled definition refuses a new Run tree, and not a child of a tree already running. assert_run_shape() and RunManager must hold the same asymmetry, because the trigger fires after the manager and never sees a start the manager already refused.

A definition references definitions of its own organization only. A platform template is forked before it is referenced, exactly as it is forked before it is run. runs_definition_fk already pairs a Run with a definition of its own organization, so the reference rule and the execution rule now say the same thing, and the reverse lookup that four definition rules depend on stays answerable inside one tenant. See runtime definitions.

Coexistence with the live agent stack

This platform is built beside the current agent stack, not on top of it. The live production stack remains the fallback until Phase 9 cutover. This is a deployment boundary, not a guarantee that every legacy module remains on agentic-platform trunk. The trunk already removes legacy chat surfaces; compare new work with trunk, not staging.

Three rules make that separation real rather than aspirational.

  1. Every platform table lives in the agent Postgres schema. The old runtime owns public.agent_runs, so the word agent is taken inside public but free as a schema. agent.runs and public.agent_runs are different tables and cannot collide, so the separation is structural rather than a naming convention someone must remember. It also survives cutover: when the old stack is deleted, nothing is renamed, because no name was ever a workaround.
    Four consequences follow, and each has bitten a project that skipped it.
    • A new schema starts with no privileges. pg_default_acl carries grants for public, auth and storage only. The creating migration must GRANT USAGE ON SCHEMA agent to anon, authenticated and service_role, and set ALTER DEFAULT PRIVILEGES, or PostgREST answers permission denied while every RLS policy is correct. RLS stays the tenancy boundary exactly as it is in public.
    • The schema must be exposed to PostgREST per environment. Local is [api] schemas in supabase/config.toml. Staging and production are a Supabase Dashboard change, and it must land before that environment applies the migration.
    • The client must ask for the schema, and it must ask once. supabase-py and supabase-js default to public. In supabase-py 2.18.1 a .schema("agent") call builds a new AsyncPostgrestClient with its own httpx session, and nothing closes it, so calling it per request leaks a connection pool per request. ac-python-api therefore holds one cached agent-schema client in src/core/database.py, beside the admin client and with the same thread-local lifetime, and every caller reads that accessor. The client sets Accept-Profile and Content-Profile together, so one accessor serves reads and writes.
    • Every write is service-role. No agent table carries an insert, update or delete RLS policy: authenticated reads its own organization and writes nothing. So a platform writer takes the admin client, never the org-scoped one. This is the opposite of the house pattern in public, and it fails silently in the dangerous direction — RLS filters a write rather than raising, so an org-scoped UPDATE returns success and changes no row.

    A platform table is a table that Data model names. Every other table is product state, and product state stays in public.
    Product state that joins to public.intel_* and public.crm_* gains nothing from the isolation. The name does not collide. The joins the table reads on every query stay inside one schema. The exposure change above is not needed. So the table keeps the house pattern: an organization_id column, RLS on all four verbs, and an entry in ORG_SCOPED_TABLES. The Signals Search prospect tables are the first of these.
    A column that names the Run which produced the row is the one reference that crosses. It carries no foreign key, exactly as ai_usage_log.agent_root_run_id does, because a real key ties a public table back into the isolated schema. See observability and operations.
    A product table takes scoped_db(organization_id), and this is the mirror of the service-role bullet. OrgScopedClient adds the organization filter for a table in ORG_SCOPED_TABLES. The admin client adds none, so an UPDATE sent through it reaches every organization and reports success. Each client is silently wrong on the other kind of table, in the opposite direction.
    Check a name against the live catalog, never against the migration files: agent_runs was created by a RENAME, so a CREATE TABLE grep reports it as free.
  2. Every platform endpoint sits under /api/v1/agentic/. The live /api/v1/agents/runs is driven by the ac CLI, and two paths one word apart would be a support burden, so the start boundary is /api/v1/agentic/runs. The segment then covers the whole surface layer, and not that one route. Most other platform paths are free today, so a per-route decision is possible. It is refused for three reasons. The prefix is what a reader greps to see the boundary the other two rules draw. It is one entry in ac-cli/scripts/audit_endpoints.py rather than one entry per surface. And each of the seven surfaces would otherwise answer the same question again, which is how a mixed scheme arrives.
  3. New code never imports the old stack. An import-linter contract in ac-python-api/pyproject.toml forbids src.agentic from importing src.workflow_engine, src.agent_runtime, src.agents, src.domains.envoy, src.domains.chat or src.domains.workflows. One import across that line and the fallback stops being real, which is why it is a CI contract and not a sentence here. The contract is directional. A composition root outside src.agentic may import both sides, which is how one worker process serves both Inngest apps, and src/core/inngest_worker.py is that root. A module inside src.agentic may not.

agentic survives in two places, and only two. A Postgres schema solves the table namespace. Python packages and URL paths have no equivalent, and src.agents and /api/v1/agents/runs are both taken by the live stack. So the module root stays src.agentic and the endpoint prefix stays /api/v1/agentic/. Tables do not, because they had a better option.

The contract is deleted in the cutover phase, when there is nothing left to isolate from.

Deployment topology

Two processes, and the split is a rule rather than a tuning choice.

ProcessOwnsNever does
Web dynoThe synchronous control plane: the HTTP API, validation, RunManager.start(), and sending eventsExecute an Inngest function
Worker dynoAll durable execution: every Inngest function, over an Inngest Connect sessionServe HTTP

If it is an Inngest function, it runs on the worker. There is no short-function exemption. A reaper that finishes in 200 milliseconds runs on the worker for the same reason a long agent task does: one place to look, one set of limits, one restart story. The moment the topology depends on how long a function takes, every new function needs that judgement made again, and eventually it is made wrong.

The worker holds a persistent outbound WebSocket to Inngest, which has three consequences worth stating.

  • There is no public /api/inngest endpoint, and no inbound request to verify. INNGEST_SIGNING_KEY stays, as the credential that opens the connection rather than the key that signs a request.
  • No HTTP timeout bounds a step. The step budget belongs to the worker, not to a router.
  • Connect needs a long lived process, so it does not work on serverless. A dyno is the right shape.

One session, two apps

The live stack's 21 Inngest functions move to the worker in Phase 1. They do not stay on inngest.fast_api.serve until cutover. Leaving them there would run two execution paths side by side for the whole build, and the rule above exists to stop exactly that: once the topology has two paths, every new function needs the judgement made again.

One process holds one WebSocket and registers two Inngest apps, because connect(apps=[...]) takes a list of (client, functions) pairs.

App idCarries
agencycore-apiThe 21 live functions, unchanged.
agencycore-agenticThe new platform functions.

A function's Inngest id embeds its app id. agent.run under agencycore-api is agencycore-api-agent.run. So the app id is part of the function's identity, and three rules follow from that one fact.

  • The live app keeps the id it already has in Inngest Cloud. Renaming it renames all 21 functions and orphans every in-flight run.
  • The split into two apps is decided now or not at all. Merging later, or splitting later, renames every function that moves.
  • The ids therefore merge at cutover only by deleting the live app, never by moving its functions into the other one.

Every registered app carries at least one function. connect() raises FunctionConfigInvalidError: no functions found when an app's list is empty, and it raises during construction, so an empty app is a boot crash rather than a degraded worker. The new app therefore ships agentic.healthcheck from its first commit. That function also replaces GET /api/inngest as the "is the worker alive" probe, which the topology no longer has.

What the worker owes on shutdown

The platform sends SIGTERM, and the Connect session then waits for the in-flight step to finish before it closes. That is the whole guarantee, and two limits sit around it.

  • The platform's own kill deadline bounds the drain. On Heroku that is 30 seconds after SIGTERM. A step that runs longer is killed mid-flight. Completed steps stay memoized, so the run resumes at the first unfinished step. A completed matching Tool claim returns its stored result. An interrupted claim keeps its lease and vendor recovery rules. Measured: re-dispatch after a kill takes roughly two minutes.
  • No process supervisor may shorten that window. honcho cannot run the worker, because it sends SIGKILL five seconds after its first child exits, whatever the platform allows. Celery drains in about a second, so the Inngest worker would get about six. A supervisor for this dyno forwards SIGTERM to every child and then waits for all of them.

max_worker_concurrency is set explicitly. Function-level concurrency is keyed per organization, so the total scales with the number of organizations, and several functions declare no limit at all. It is the only global limit the worker has.

Data model

Every table below lives in the agent Postgres schema, per rule 1 of coexistence. Existing production tables stay in public and are reused where noted by the detailed docs; ai_usage_log and ai_usage_daily are the ones that matter here.

TableHolds
agent.definitionsAgent, Workflow and Skill draft + published config, keyed by kind
agent.triggersEvent pattern/filter/target/input template/config
agent.runsEvery product execution + immutable execution snapshot
agent.run_controlThe cancellation request for one Run
agent.sessionsThe Agno message history for one agent Run, across segments
agent.approvalsPending/resolved human decisions
agent.toolsModel-facing Tool catalogue
agent.memoriesDurable preference/observation with provenance
agent.knowledge_sourcesPlatform/organization document, upload or sync source
agent.knowledge_chunksChunk text + embedding, with the embedding model pinned per row
agent.policiesAction/checkpoint rule
agent.cost_ceilingsCost ceilings. Rate and concurrency live in Inngest; count ceilings live in the definition budget
agent.policy_decisionsEvery admission decision, and every decision that refused, gated or stopped work
agent.provider_jobsOne asynchronous provider job: the tenant, the run tree, the submit span, the vendor job id, the state, the result and the settled cost
agent.idempotency_keysDurable Tool and webhook effect claims and stored results. It is also the Tool replay journal. A Run start is not in it: agent.runs carries its own start key
agent.spansOne unit of work inside a Run, carrying organization_id and root_run_id
agent.conversationsPlatform conversation: organization, creator, title, summary, last activity
agent.conversation_messagesRole, sender, text, attachment count, originating run_id when one exists

Remote channel install tables and MCP connection tables stay deferred. The current web-first slice does not add them to the schema.

ai_usage_log / ai_usage_daily remain the cost truth; spans point at usage rows instead of storing a second price. ai_usage_log gains one column, agent_root_run_id, so a tree total is one indexed aggregate rather than a filter built from the span tree. See observability and operations.

Not building in V1

  • A second agent runtime or backend selector.
  • Claude/managed-agent execution as a target backend.
  • Agno Teams, Agno workflows, AgentOS approvals or Agno background lifecycle as product primitives.
  • Arbitrary workflow code, loops or dynamic fan-out. A constrained visual editor over the same eight node types is in scope. See workflow visualizer.
  • A custom scheduler, queue, workflow engine or durable checkpoint system.
  • A general policy language such as arbitrary Python/SQL/Rego/CEL.
  • A permission table duplicating user roles and Agent tool grants.
  • A second rate limiter or second cost meter.
  • A retrieval Agent.
  • A vector database separate from Postgres/pgvector.
  • A separate store for working or episodic memory.
  • Direct database access or generic api_request Tools for Agents.
  • A channel-specific Agent or prompt.
  • Email inside Channel Gateway.
  • Conversation attachments. A file enters as an organization knowledge source, never through a chat.
  • A Telegram or WhatsApp adapter in V1.
  • An interactive channel as a PlatformEvent producer.
  • A durable event table beside Run spans.
  • A replay log for SSE in V1; reconnect by refetching durable state.
  • Definition revision/history/rollback UI in V1.
  • Run steering. Cancel and restart covers V1.
  • A phase enum beside the Run status.
  • An index of which Runs wait for which event; the wait node carries its own correlation. agent.provider_jobs is not that index: it is the durable state of one external job, and the Run it names is the tree the cost belongs to. A wait on a provider job still declares its own correlation.
  • A notification system for approvals; one outbound intent covers V1.

Cross-document invariants

Before merging any future design update, verify:

  1. Front Door and Triggers still call the same Run start boundary.
  2. RunManager is the only product Run lifecycle owner.
  3. Agno is hidden behind AgentRuntime; no Agno type leaks into policy, tools, definitions or surfaces.
  4. Every Agent action still goes through ToolInvoker.
  5. Tool write/send paths still use Idempotency.
  6. Policy is live at the next checkpoint and does not become Agent prompt logic.
  7. Email remains Nylas/tool/trigger-owned.
  8. Definition edits do not mutate in-flight Runs.
  9. Durable spans are written before best-effort live events.
  10. Product-specific CRM/workflow design remains outside this platform set.
  11. One PlatformEvent still reaches both readers: the trigger matcher, and any Run waiting on it.
  12. A waiting Run is never failed on its heartbeat. The wait sweep is the one writer that ends one, and only past waiting_expires_at plus the grace window.
  13. Every model call is metered, including the Front Door turn that precedes a Run.
  14. AccrualChecker reads ai_usage_log and returns a decision. It never reads the rollup and never raises.
  15. mark_waiting() and resume() stay a pair, so a Run never executes while it reads waiting.
  16. A suspended Inngest run holds no concurrency slot. A workflow tree depends on it.
  17. SpanRecorder writes heartbeat_at, so the reaper has a safe stale_after.
  18. Every write and send Tool declares repeatable.
  19. No type outside the context package is named Context, and no bare type is named Event.
  20. A pending approval is cancelled by an erasure request, never redacted in place.
  21. The principal narrows at a checkpoint and never widens.
  22. New platform code imports nothing from the legacy agent stack, every new module lives under src.agentic, which is the path the import contract binds, and every new platform table is created in the agent schema, never in public. Product state keeps the house pattern in public, per the exception in coexistence rule 1.
  23. The web process serves no Inngest function. Every Inngest function runs on the Connect worker.
  24. A function's Inngest id embeds its app id. No app is renamed, and no function moves between apps, once it has run.
  25. A Run start is guarded by a unique index on agent.runs, so the insert is the claim and no start path needs a transaction.
  26. Every Run lifecycle write is conditional on the legal from-states, so a terminal Run stays terminal.
  27. Every bounded payload passes the one bound() helper. No layer writes its own truncation.
  28. No surface returns an unbounded list, and no detail response embeds one. A related set is a page of a list route.
  29. Every refusal a domain component returns maps to exactly one HTTP status, in one table on the page that owns the route.
  30. The Run snapshot and the Run input are size checked and never truncated. Both refuse the start rather than drop an item, because a drop there changes what the Run does and no field records it.
  31. The principal grant lives in agent.runs.principal, and no other page lists it inside the snapshot.
  32. A disabled definition refuses a new Run tree and allows a child of a running tree, in the trigger and in RunManager alike.
  33. Every definition write that carries authored configuration -- the draft save and the publish -- is conditional on expected_updated_at, so a stale writer reloads rather than overwriting. disable, enable and delete_draft carry no token: the first is an emergency switch that must not answer stale, and none of the three overwrites authored work. expected_updated_at travels as an opaque string, because a millisecond-precision client that re-formats it makes every write answer stale.
  34. Agno is imported under runtime/agent/agno/ and nowhere else. The tool adapter counts.
  35. No exception raised by ToolInvoker inside the Agno loop is left for Agno to swallow. The adapter classifies every one of them into the segment's stop signal.
  36. The segment stop signal lives on the segment. No mutable run state sits on a shared runtime instance.
  37. Every segment carries the system block it should run with. context = None means reuse the stored one, never trust the framework to keep it.
  38. A write or send call is decided and executed outside the Agno loop. Agno pauses, ToolInvoker runs the call, and the result fills that call's own empty slot.
  39. No query filters on a list of identifiers that grows with a Run tree. Denormalize root_run_id and filter on it, because PostgREST sends the filter in the URL and Kong refuses a request line over 8 KB.
  40. One pricer answers the cost of a model call, and the count it is given excludes the cache reads. The vendor reports tokens and no price, and a provider that folds cache reads into its input count prices them two times.
  41. The agent loop is left at the tool adapter or at the drain loop. No code abandons the framework event iterator, because an abandoned iterator leaves an open span and an unwritten session.
  42. A session write names the segment that produced it and never goes backwards, and the database refuses one that does. Two workers can hold one segment, and an invariant that lives only in the application is one forgotten predicate away from putting an older message history back with nothing reporting the loss.
  43. No exception the agent framework raises reaches the caller. Every model fault is kept beside the segment stop and classified after the stream ends, on the status the vendor sent and never on the name of the exception class: the rate limit type subclasses the refusal type, so reading the names inverts the rule and one 429 ends a run a replay would have saved.
  44. An asynchronous provider job stores its state transition before the platform emits the completion event. The row is the durable truth and the event is a wake-up signal, so a lost event costs a timeout and never a lost result.
  45. A provider cost settles one time, guarded by a conditional write on a null usage_id. It settles off the Run frame, so a Run that was cancelled or timed out still carries the money it spent.

Product designs

These apply the platform to AgencyCore product features. They do not redefine runtime, tools, policy, context, triggers, idempotency or observability.

PageOwns
Agentic CRMCross-product lead-generation loop, the three product-state layers, scoring, review boundary and shared CRM context
Signals SearchOne bounded discovery workflow: companies, signals, relevant people, one Organization Prospect result, saved search, scheduled monitoring and CRM promotion
Email sequence workflowEnvoy durable outreach: personalized drafting, send, approval, waits, replies, follow-ups and Nylas boundary
Front door general chatThe default answer path: answer from supplied context, cite what it used, ask when context is insufficient, and delegate real work
Front door builder chatConversational authoring: one Definition Builder Agent turns chat into a validated Agent/Workflow draft and publishes it through DefinitionService
Human review inboxThe product view over the shared approval row: review types, page structure, filters, empty and stale states

Two earlier product pages are retired:

  • Signals Search replaces Sonar and Headhunter. Company discovery and people discovery are one bounded workflow producing one Organization Prospect, not two chained product Runs.
  • The email sequence workflow replaces Icebreaker. Personalized drafting is a stage of durable outreach execution, not a separate product.

Product apps stay standalone user-facing products. Product state belongs to the product domain; generic execution state belongs to RunManager and Runs. No product feature gets its own scheduler, queue, Run table, policy engine, memory store or integration layer.