Surfaces

Every product surface and its API contract. Web chat goes through the gateway; every schema-native surface calls the domain API.

1 min read Updated Sep 3, 2026

Surfaces

A surface is a place where a person uses the platform. One question decides its path:

Does the surface speak the AgencyCore platform schema?

A conversational client does not. It sends free text, so it goes through a channel adapter and the Front Door. A control surface does speak the schema, so it calls the domain API.

1 · Interfaces — how work gets in
four kinds of caller · three ways in · one boundary · one way back
1 · Interfaces — how work gets infour kinds of caller · three ways in · one boundary · one way back
WHO CALLS — one question decides the path: does the caller already speak our schema?
WHO CALLS — one question decides the path: does the caller already speak our schema?
PERSON — free text

web chat · Slack
WhatsApp and Telegram later

it does NOT speak our schema
PERSON — free textweb chat · SlackWhatsApp and Telegram laterit does NOT speak our schema
PERSON — our schema

approval inbox · agent builder
run explorer
trigger, policy and connection admin
PERSON — our schemaapproval inbox · agent builderrun explorertrigger, policy and connection admin
AI AGENT — acts for a person

the ac CLI on a user seat
works today

an MCP server is planned, not designed
AI AGENT — acts for a personthe ac CLI on a user seatworks todayan MCP server is planned, not designed
MACHINE — no person

Nylas mail · vendor webhook
cron · a CRM row moved

it reports, it does not ask
MACHINE — no personNylas mail · vendor webhookcron · a CRM row movedit reports, it does not ask
MCP = TODO
MCP = TODO
1 · CHANNEL GATEWAY — one seam, split by the vendor ACK deadline
1 · CHANNEL GATEWAY — one seam, split by the vendor ACK deadline
InboundAdapter
external_workspace_id · verify · decode
InboundAdapterexternal_workspace_id · verify · decode
claim the delivery id · durable enqueue · ACK under 3 seconds
the claim completes only after the enqueue succeeds
claim the delivery id · durable enqueue · ACK under 3 secondsthe claim completes only after the enqueue succeeds
InboundWorker
one worker per session key, so turns stay ordered
InboundWorkerone worker per session key, so turns stay ordered
ChannelIdentityService — their user becomes ours
ChannelIdentityService — their user becomes ours
ChannelSessionService — a thread becomes a conversation
ChannelSessionService — a thread becomes a conversation
No manager class sits above these parts.
No manager class sits above these parts.
NormalizedMessage — no vendor field reaches the front door
NormalizedMessage — no vendor field reaches the front door
2 · DOMAIN API — it already speaks the schema, so it needs no model
2 · DOMAIN API — it already speaks the schema, so it needs no model
seven routers, one per surface
conversations · approvals · definitions · runs
triggers · policies · connections
seven routers, one per surfaceconversations · approvals · definitions · runstriggers · policies · connections
SurfaceVisibility
visibility ONLY. Policy decides every action below it
SurfaceVisibilityvisibility ONLY. Policy decides every action below it
read models only
a router never returns a domain entity
read models onlya router never returns a domain entity
cursor pages · an Idempotency-Key on anything that starts work
cursor pages · an Idempotency-Key on anything that starts work
the client sends an intent, and never writes state
the client sends an intent, and never writes state
The API applies an explicit organization filter. Another organization's row is 404, never 403.
The organization comes from token claims, never from the body.
The API applies an explicit organization filter. Another organization's row is 404, never 403.The organization comes from token claims, never from the body.
3 · TRIGGER PRODUCERS — one shape, and the id rule is the only real difference
3 · TRIGGER PRODUCERS — one shape, and the id rule is the only real difference
EventProducer
nylas · webhook · cron · crm
EventProducernylas · webhook · cron · crm
verify the source → normalize → mint the id → emit
verify the source → normalize → mint the id → emit
event.id is DERIVED, never random
the vendor delivery id · the provider delivery id
the schedule + the tick · the row id + its version
event.id is DERIVED, never randomthe vendor delivery id · the provider delivery idthe schedule + the tick · the row id + its version
A webhook ACKs first, then dispatches on a worker.
cron and crm keep nobody waiting, so both dispatch direct.
A webhook ACKs first, then dispatches on a worker.cron and crm keep nobody waiting, so both dispatch direct.
PlatformEvent — one transient envelope
PlatformEvent — one transient envelope
FRONT DOOR — one structured decision in, one deterministic action out
FRONT DOOR — one structured decision in, one deterministic action out
1 · context — FRONT_DOOR_CONTEXT_POLICY, already scoped
1 · context — FRONT_DOOR_CONTEXT_POLICY, already scoped
2 · shortlist — CapabilityIndex, about ten published capabilities
2 · shortlist — CapabilityIndex, about ten published capabilities
3 · build_front_door_agent() — ONE model call
output_schema = FrontDoorOutcome · no action tools attached
3 · build_front_door_agent() — ONE model calloutput_schema = FrontDoorOutcome · no action tools attached
FrontDoorService validates the outcome, then acts
FrontDoorService validates the outcome, then acts
answer
no run starts
answerno run starts
clarify
no run starts
clarifyno run starts
delegate
→ start()
delegate→ start()
control_run
→ cancel()
control_run→ cancel()
Approval is NOT a front-door outcome. Delegation starts the normal run path,
and admission decides. A hallucinated capability id is rejected before any run exists.
Approval is NOT a front-door outcome. Delegation starts the normal run path,and admission decides. A hallucinated capability id is rejected before any run exists.
EVENT ROUTER — one event, two readers
EVENT ROUTER — one event, two readers
EventRouter — the one fan-out point. It removes a wait-index table
EventRouter — the one fan-out point. It removes a wait-index table
TriggerMatcher
the same ConditionEvaluator that Policy uses
TriggerMatcherthe same ConditionEvaluator that Policy uses
inngest.send
platform/<event.type>
inngest.sendplatform/<event.type>
TriggerDispatchService
enabled? · self_reentry?
render_input()
TriggerDispatchServiceenabled? · self_reentry?render_input()
a WAITING run resumes
no new run starts
a WAITING run resumesno new run starts
idempotency_key = trigger:<trigger_id>:event:<event_id>
Three deliveries of one webhook start one run, and resume one run once.
Every skip records a reason: disabled · duplicate · self_reentry · debounced · concurrency · budget
idempotency_key = trigger:<trigger_id>:event:<event_id>Three deliveries of one webhook start one run, and resume one run once.Every skip records a reason: disabled · duplicate · self_reentry · debounced · concurrency · budget
The trigger layer stops at RunManager. It executes no agent,
and it owns no second scheduler.
The trigger layer stops at RunManager. It executes no agent,and it owns no second scheduler.
THE ONE BOUNDARY — every caller ends here, or nowhere
THE ONE BOUNDARY — every caller ends here, or nowhere
StartRunCommand — definition · input · actor · source · conversation · idempotency_key
StartRunCommand — definition · input · actor · source · conversation · idempotency_key
RunManager.start() · RunManager.cancel()
RunManager.start() · RunManager.cancel()
claim the key · resolve the published definition · freeze the run snapshot · mint the principal grant
claim the key · resolve the published definition · freeze the run snapshot · mint the principal grant
FrontDoorProgressEmitter
four semantic events, then the run owns progress

Open decision 5: this deletes if the run stream
opens when start() accepts the command
FrontDoorProgressEmitterfour semantic events, then the run owns progressOpen decision 5: this deletes if the run streamopens when start() accepts the command
→ the runtime layer, next page

It executes the frozen snapshot.
There is no private synchronous
execution path around this box.
→ the runtime layer, next pageIt executes the frozen snapshot.There is no private synchronousexecution path around this box.
BACK TO THE CALLER — inbound converged on one boundary; outbound diverges into three
BACK TO THE CALLER — inbound converged on one boundary; outbound diverges into three
NO EMAIL ADAPTER — Nylas owns email transport. Inbound mail is an Event; outbound mail is the email.send Tool.

The front door never inserts a run row, and it never calls Inngest.

No second expression language — TriggerMatcher reuses the ConditionEvaluator that Policy uses.

An unverified sender never becomes an actor identity, and never starts a run.

Answer, approval and error are durable and deduped. Progress is best effort, one edit per 5 seconds. An approval resolves once.

Web declares no OutboundAdapter, and therefore no capabilities. It has no path around the gateway inbound.

ChannelActionHandler turns a Slack button into ONE approval decision, and Human Review still resolves it after an uninstall.
NO EMAIL ADAPTER — Nylas owns email transport. Inbound mail is an Event; outbound mail is the email.send Tool.The front door never inserts a run row, and it never calls Inngest.No second expression language — TriggerMatcher reuses the ConditionEvaluator that Policy uses.An unverified sender never becomes an actor identity, and never starts a run.Answer, approval and error are durable and deduped. Progress is best effort, one edit per 5 seconds. An approval resolves once.Web declares no OutboundAdapter, and therefore no capabilities. It has no path around the gateway inbound.ChannelActionHandler turns a Slack button into ONE approval decision, and Human Review still resolves it after an uninstall.
free text
free text
schema
schema
schema
schema
an event
an event
straight to the boundary
straight to the boundary
exactly one run
exactly one run
run events
run events
THE RUN
THE RUN
HOW IT GETS THERE — three deliveries, and no shared path
HOW IT GETS THERE — three deliveries, and no shared path
WHO RECEIVES IT
WHO RECEIVES IT
THE RUN

its events, and the one
decision it cannot make
THE RUNits events, and the onedecision it cannot make
ChannelNotifier
ChannelNotifier
OutboundIntent
OutboundIntent
OutboundAdapter.send()
OutboundAdapter.send()
Slack
a remote channel
Slacka remote channel
RunStreamService
ConversationStreamService
RunStreamServiceConversationStreamService
SSE
subscribe only, publish nothing
SSEsubscribe only, publish nothing
web chat
web chat
ApprovalService
ApprovalService
the approval inbox
is the floor
the approval inboxis the floor
a person
a person
durable
durable
live
live
once
once
Text is not SVG - cannot display
1 · Interfaces — how work gets in
four kinds of caller · three ways in · one boundary · one way back
1 · Interfaces — how work gets infour kinds of caller · three ways in · one boundary · one way back
WHO CALLS — one question decides the path: does the caller already speak our schema?
WHO CALLS — one question decides the path: does the caller already speak our schema?
PERSON — free text

web chat · Slack
WhatsApp and Telegram later

it does NOT speak our schema
PERSON — free textweb chat · SlackWhatsApp and Telegram laterit does NOT speak our schema
PERSON — our schema

approval inbox · agent builder
run explorer
trigger, policy and connection admin
PERSON — our schemaapproval inbox · agent builderrun explorertrigger, policy and connection admin
AI AGENT — acts for a person

the ac CLI on a user seat
works today

an MCP server is planned, not designed
AI AGENT — acts for a personthe ac CLI on a user seatworks todayan MCP server is planned, not designed
MACHINE — no person

Nylas mail · vendor webhook
cron · a CRM row moved

it reports, it does not ask
MACHINE — no personNylas mail · vendor webhookcron · a CRM row movedit reports, it does not ask
MCP = TODO
MCP = TODO
1 · CHANNEL GATEWAY — one seam, split by the vendor ACK deadline
1 · CHANNEL GATEWAY — one seam, split by the vendor ACK deadline
InboundAdapter
external_workspace_id · verify · decode
InboundAdapterexternal_workspace_id · verify · decode
claim the delivery id · durable enqueue · ACK under 3 seconds
the claim completes only after the enqueue succeeds
claim the delivery id · durable enqueue · ACK under 3 secondsthe claim completes only after the enqueue succeeds
InboundWorker
one worker per session key, so turns stay ordered
InboundWorkerone worker per session key, so turns stay ordered
ChannelIdentityService — their user becomes ours
ChannelIdentityService — their user becomes ours
ChannelSessionService — a thread becomes a conversation
ChannelSessionService — a thread becomes a conversation
No manager class sits above these parts.
No manager class sits above these parts.
NormalizedMessage — no vendor field reaches the front door
NormalizedMessage — no vendor field reaches the front door
2 · DOMAIN API — it already speaks the schema, so it needs no model
2 · DOMAIN API — it already speaks the schema, so it needs no model
seven routers, one per surface
conversations · approvals · definitions · runs
triggers · policies · connections
seven routers, one per surfaceconversations · approvals · definitions · runstriggers · policies · connections
SurfaceVisibility
visibility ONLY. Policy decides every action below it
SurfaceVisibilityvisibility ONLY. Policy decides every action below it
read models only
a router never returns a domain entity
read models onlya router never returns a domain entity
cursor pages · an Idempotency-Key on anything that starts work
cursor pages · an Idempotency-Key on anything that starts work
the client sends an intent, and never writes state
the client sends an intent, and never writes state
The API applies an explicit organization filter. Another organization's row is 404, never 403.
The organization comes from token claims, never from the body.
The API applies an explicit organization filter. Another organization's row is 404, never 403.The organization comes from token claims, never from the body.
3 · TRIGGER PRODUCERS — one shape, and the id rule is the only real difference
3 · TRIGGER PRODUCERS — one shape, and the id rule is the only real difference
EventProducer
nylas · webhook · cron · crm
EventProducernylas · webhook · cron · crm
verify the source → normalize → mint the id → emit
verify the source → normalize → mint the id → emit
event.id is DERIVED, never random
the vendor delivery id · the provider delivery id
the schedule + the tick · the row id + its version
event.id is DERIVED, never randomthe vendor delivery id · the provider delivery idthe schedule + the tick · the row id + its version
A webhook ACKs first, then dispatches on a worker.
cron and crm keep nobody waiting, so both dispatch direct.
A webhook ACKs first, then dispatches on a worker.cron and crm keep nobody waiting, so both dispatch direct.
PlatformEvent — one transient envelope
PlatformEvent — one transient envelope
FRONT DOOR — one structured decision in, one deterministic action out
FRONT DOOR — one structured decision in, one deterministic action out
1 · context — FRONT_DOOR_CONTEXT_POLICY, already scoped
1 · context — FRONT_DOOR_CONTEXT_POLICY, already scoped
2 · shortlist — CapabilityIndex, about ten published capabilities
2 · shortlist — CapabilityIndex, about ten published capabilities
3 · build_front_door_agent() — ONE model call
output_schema = FrontDoorOutcome · no action tools attached
3 · build_front_door_agent() — ONE model calloutput_schema = FrontDoorOutcome · no action tools attached
FrontDoorService validates the outcome, then acts
FrontDoorService validates the outcome, then acts
answer
no run starts
answerno run starts
clarify
no run starts
clarifyno run starts
delegate
→ start()
delegate→ start()
control_run
→ cancel()
control_run→ cancel()
Approval is NOT a front-door outcome. Delegation starts the normal run path,
and admission decides. A hallucinated capability id is rejected before any run exists.
Approval is NOT a front-door outcome. Delegation starts the normal run path,and admission decides. A hallucinated capability id is rejected before any run exists.
EVENT ROUTER — one event, two readers
EVENT ROUTER — one event, two readers
EventRouter — the one fan-out point. It removes a wait-index table
EventRouter — the one fan-out point. It removes a wait-index table
TriggerMatcher
the same ConditionEvaluator that Policy uses
TriggerMatcherthe same ConditionEvaluator that Policy uses
inngest.send
platform/<event.type>
inngest.sendplatform/<event.type>
TriggerDispatchService
enabled? · self_reentry?
render_input()
TriggerDispatchServiceenabled? · self_reentry?render_input()
a WAITING run resumes
no new run starts
a WAITING run resumesno new run starts
idempotency_key = trigger:<trigger_id>:event:<event_id>
Three deliveries of one webhook start one run, and resume one run once.
Every skip records a reason: disabled · duplicate · self_reentry · debounced · concurrency · budget
idempotency_key = trigger:<trigger_id>:event:<event_id>Three deliveries of one webhook start one run, and resume one run once.Every skip records a reason: disabled · duplicate · self_reentry · debounced · concurrency · budget
The trigger layer stops at RunManager. It executes no agent,
and it owns no second scheduler.
The trigger layer stops at RunManager. It executes no agent,and it owns no second scheduler.
THE ONE BOUNDARY — every caller ends here, or nowhere
THE ONE BOUNDARY — every caller ends here, or nowhere
StartRunCommand — definition · input · actor · source · conversation · idempotency_key
StartRunCommand — definition · input · actor · source · conversation · idempotency_key
RunManager.start() · RunManager.cancel()
RunManager.start() · RunManager.cancel()
claim the key · resolve the published definition · freeze the run snapshot · mint the principal grant
claim the key · resolve the published definition · freeze the run snapshot · mint the principal grant
FrontDoorProgressEmitter
four semantic events, then the run owns progress

Open decision 5: this deletes if the run stream
opens when start() accepts the command
FrontDoorProgressEmitterfour semantic events, then the run owns progressOpen decision 5: this deletes if the run streamopens when start() accepts the command
→ the runtime layer, next page

It executes the frozen snapshot.
There is no private synchronous
execution path around this box.
→ the runtime layer, next pageIt executes the frozen snapshot.There is no private synchronousexecution path around this box.
BACK TO THE CALLER — inbound converged on one boundary; outbound diverges into three
BACK TO THE CALLER — inbound converged on one boundary; outbound diverges into three
NO EMAIL ADAPTER — Nylas owns email transport. Inbound mail is an Event; outbound mail is the email.send Tool.

The front door never inserts a run row, and it never calls Inngest.

No second expression language — TriggerMatcher reuses the ConditionEvaluator that Policy uses.

An unverified sender never becomes an actor identity, and never starts a run.

Answer, approval and error are durable and deduped. Progress is best effort, one edit per 5 seconds. An approval resolves once.

Web declares no OutboundAdapter, and therefore no capabilities. It has no path around the gateway inbound.

ChannelActionHandler turns a Slack button into ONE approval decision, and Human Review still resolves it after an uninstall.
NO EMAIL ADAPTER — Nylas owns email transport. Inbound mail is an Event; outbound mail is the email.send Tool.The front door never inserts a run row, and it never calls Inngest.No second expression language — TriggerMatcher reuses the ConditionEvaluator that Policy uses.An unverified sender never becomes an actor identity, and never starts a run.Answer, approval and error are durable and deduped. Progress is best effort, one edit per 5 seconds. An approval resolves once.Web declares no OutboundAdapter, and therefore no capabilities. It has no path around the gateway inbound.ChannelActionHandler turns a Slack button into ONE approval decision, and Human Review still resolves it after an uninstall.
free text
free text
schema
schema
schema
schema
an event
an event
straight to the boundary
straight to the boundary
exactly one run
exactly one run
run events
run events
THE RUN
THE RUN
HOW IT GETS THERE — three deliveries, and no shared path
HOW IT GETS THERE — three deliveries, and no shared path
WHO RECEIVES IT
WHO RECEIVES IT
THE RUN

its events, and the one
decision it cannot make
THE RUNits events, and the onedecision it cannot make
ChannelNotifier
ChannelNotifier
OutboundIntent
OutboundIntent
OutboundAdapter.send()
OutboundAdapter.send()
Slack
a remote channel
Slacka remote channel
RunStreamService
ConversationStreamService
RunStreamServiceConversationStreamService
SSE
subscribe only, publish nothing
SSEsubscribe only, publish nothing
web chat
web chat
ApprovalService
ApprovalService
the approval inbox
is the floor
the approval inboxis the floor
a person
a person
durable
durable
live
live
once
once
Text is not SVG - cannot display
The whole interfaces layer on one page. Four kinds of caller, three ways in, and one boundary they all end at. A conversational surface speaks free text, so it takes two hops through the Channel Gateway and the Front Door. A control surface already speaks the schema, so it calls the domain API directly, as the ac CLI does. A machine reports an event, and the Event Router fans it to two readers: one may start a run, and one resumes a run that already waits. The last band is the way back, which is the only part of the layer that diverges.
SurfacePathPurpose
Web chatWeb adapter -> Front DoorConversational control
Approval InboxDirect APIResolve pending policy decisions
Prospect reviewDirect APIRead and curate organization prospects
Saved searchesDirect APISave a repeatable brief, start it and read its latest diff
Agent BuilderDirect APIEdit, validate, publish and disable definitions
Run ExplorerDirect API + SSEInspect Run state, spans, usage and live progress
Trigger adminDirect APIAuthor what starts a Run without a person
Policy adminDirect APIAuthor the rules and the cost ceilings
ConnectionsDirect APIInstall and revoke provider credentials. Remote channels and MCP servers are deferred

No AgentBuilderService, ApprovalInboxService, ProspectReviewService, SavedSearchSurfaceService or RunExplorerService layer exists. Each surface is a projection over a domain component that is already built.

The first six get one section each below. The last three are ordinary configuration CRUD, and one section covers all three together.

Shared API rules

These apply to every route on this page.

ConcernRule
PathEvery route sits under /api/v1/agentic/. The prefix names the platform boundary, and one entry in ac-cli/scripts/audit_endpoints.py covers every surface. See coexistence.
AuthenticationThe caller sends a Supabase user JWT. The organization comes from the token claims, never from the request body.
TenancyA row in another organization returns 404, never 403. A repository for the agent schema writes the organization_id filter itself. A repository for product state in public, including prospects, takes scoped_db(organization_id). No router writes either filter.
AuthorizationA surface checks visibility. Policy decides governed effects below it. Product curation, such as watch and dismiss, is not an approval.
PaginationEvery list of stored rows is cursor paginated, and none answers an unbounded set. GET /tools is exempt while its catalogue is process-local. The organization scoped catalogue pages like every other list.
IdempotencyA route that starts work needs an Idempotency-Key header. Every start accepts 1 to 255 characters. A missing or oversized key is 400. The route namespaces it per caller before it becomes StartRunCommand.idempotency_key, because the stored key is unique per organization and two people share one organization.
Repeated writesA repeated resolve or cancel returns 200 with the current state. It does not return 409.
Repeated startA start whose Idempotency-Key already created a Run returns 200 with that Run. There is no 409: the key lives on the Run row, so a duplicate always reads a committed Run.
Lifecycle fieldsThe client sends an intent, such as publish. The client never writes state or status.

Web chat

Web chat is a first-party client. It is authenticated, so the adapter verifies no signature, resolves no workspace and links no external user. What it keeps is the shape a later channel lands against: one NormalizedMessage, one Front Door turn, one Run boundary. Channel gateway § What the web slice builds lists what this slice does not build.

The turn

TEXT
POST /conversations/{id}/messages
      -> WebChannelAdapter decodes the request
      -> durable enqueue, concurrency key = conversation_id
      -> 202 {message_id}

worker
      -> NormalizedMessage -> FrontDoorService.handle()
      -> answer | clarify -> conversation stream
      -> delegate product -> CapabilityStarter.start_resolved() -> RunManager.start() -> run.started
      -> delegate custom  -> RunManager.start() -> run.started

client
      -> conversation stream for the turn
      -> Run stream for the work

The HTTP request never waits for a model call. The answer arrives on the stream.

The enqueue is keyed on the conversation, not on the message. One turn at a time per conversation. A key on message_id would let two front door turns interleave in one conversation, and the second turn would read context the first had not written yet.

⚠️ The key buys mutual exclusion, and it does not buy ordering. Two messages enqueue in the order the route sends them, and an idle queue starts them in that order. A retry breaks it: a run in backoff holds no concurrency slot, so a later message can start while an earlier turn waits to try again. Mutual exclusion is the property the paragraph above needs, and it holds in every case, retries included. Order holds on the path where neither turn retried. So the contract test asserts that two turns never interleave, and it asserts their order only when neither retried.

The message row is written before the 202, not by the worker. The response names a message_id, and a client that reloads immediately must see its own message. A row the worker wrote would appear only after the queue drained.

message_id is still the idempotency key for the message itself. It stops one duplicate POST becoming two turns.

Routes

TEXT
GET    /api/v1/agentic/conversations                   newest activity first
POST   /api/v1/agentic/conversations                   {title?} -> 201
GET    /api/v1/agentic/conversations/{id}/messages     newest first
POST   /api/v1/agentic/conversations/{id}/messages     {text} -> 202 {message}
GET    /api/v1/agentic/conversations/{id}/stream

POST /conversations starts no work, so it takes no Idempotency-Key. It reads the organization and the creator from the token, and it takes one optional title of 200 characters at most.

POST /messages takes one text field. It takes no role, no attachment_count and no sender: the route writes role = 'user' and the sender of the token. Both writes answer the whole message row, so a client that posts renders the message without a second read.

Each list pages newest first. A person opens a conversation on the last turn, and opens the list on the conversation that moved last. The two indexes ENG-2240 built serve a backward scan unchanged. The comment in that migration says oldest first, and it is stale.

POST /messages moves last_activity_at. The list orders on that column, so the route that writes the message writes the clock. It is a second statement and not a transaction: a bump that fails leaves the order stale, and the next turn repairs it.

Conversation stream

The stream carries the turn, not the work.

TEXT
message.created     the inbound message was persisted
progress            thinking | checking_context | finding_capability | preparing_task
message.completed   an answer, a clarify question, a cancel confirmation, or a refusal
run.started         carries run_id; the Run stream owns everything after this
turn.failed         carries a reason; no Run was created

Each frame carries conversation_id and the inbound message_id, so a client that holds two turns attributes each frame to one of them.

JSON
{"type": "message.created",   "conversation_id": "…", "message_id": "…", "message": {}}
{"type": "progress",          "conversation_id": "…", "message_id": "…", "state": "thinking"}
{"type": "message.completed", "conversation_id": "…", "message_id": "…", "message": {}}
{"type": "run.started",       "conversation_id": "…", "message_id": "…", "run_id": "…"}
{"type": "turn.failed",       "conversation_id": "…", "message_id": "…", "reason": "…"}

message is the row shape that GET /messages answers, and it is the same shape in both frames. So the client holds one message list and applies one upsert by id, whatever the source. A frame and a page never disagree about what a message is.

The turn function publishes all five, and the route publishes none. One publisher keeps the order of a turn true, and it leaves POST /messages a write and an enqueue. The cost is named: the concurrency key admits one turn per conversation, so a second message queued behind a live turn carries no message.created until that turn ends. The poster already holds its own row off the 202, and a second tab reconciles on the GET /messages it reads when it opens the stream.

The four progress states reach the stream through a callback, and not through a wrapper. FrontDoorService.handle() takes on_progress. It emits checking_context before the context wait and finding_capability before the capability wait. A turn function that wrapped handle() from the outside could publish thinking and preparing_task only, and the whole span the person waits through would be silent. So the turn function passes a callback that publishes to CONVERSATION_EVENTS, and the default is a no-op for a caller that streams nothing.

run.started is a conversation event, and it is not a run event. The run publisher writes run.updated, run.completed and run.failed, and it writes no run.started. This frame says the turn handed the work over. Everything after it is on the Run stream.

The Front Door returns structured output, so an answer cannot stream tokens. Web chat shows progress, then one complete answer. Token deltas belong to an Agent Run, and they arrive on the Run stream.

⚠️ Subscribe, then send. Redis Pub/Sub keeps no backlog, so an event published before the client subscribed reaches nobody. A fast answer can complete between the 202 and a stream the client opens afterwards, and the turn then appears to hang until the person reloads. The client opens GET /conversations/{id}/stream before it posts the message, and it keeps that stream open for the life of the conversation view.

⚠️ The stream has no terminal event, so it never closes on content. A Run ends; a conversation does not. The only bound is the connection lifetime, after which the client reconnects and refetches GET /messages. This is the same reconnect contract the Run stream uses, and § Reconnect owns it.

⚠️ A delegate turn leaves two streams open. The conversation stream stays open after run.started, because the next turn and any turn.failed arrive on it. The Run stream carries the work. The client closes the Run stream on a terminal event, and it closes the conversation stream when the person leaves the view.

Every outcome writes one durable row, and that is what makes the refetch total. A live event is a hint here exactly as it is on the Run stream. A client that missed a frame reads GET /messages and sees the same answer, so no outcome can leave a turn hanging.

OutcomeWhat the stream carriesWhat the turn writes
answermessage.completedone assistant row
clarifymessage.completed, holding the questionone assistant row
delegate, admittedmessage.completed, then run.startedone assistant row, carrying run_id
control_run, cancelmessage.completed, holding the confirmationone assistant row
Policy denied the delegationmessage.completed, holding the denial reasonone assistant row
The organization is over its day ceilingmessage.completed, holding the refusal textone assistant row, and no usage row
The turn failedturn.failed, holding one reasonone system row, holding the same reason

A denial carries no run.started. RunManager writes a run row for a policy denial, and the run holds the answer, but nothing on it is worth watching. A frame that opened a Run stream over a run that is already failed would answer one terminal frame and close. The denial reason is the whole answer, and message.completed carries it.

A delegation writes its own row, and agent.conversation_messages.run_id is what it fills. Without it a reload shows the question and no reply, and the person cannot reach the work their message started. The row names the capability and carries the run, and the Run stream carries everything after it.

⚠️ A failed turn writes a system row, and ENG-2240 could not hold one.conversation_messages_reply_shape read (role = 'assistant') = (in_reply_to IS NOT NULL), so a system row carried a null in_reply_to, and uq_conversation_messages_reply compares NULLS DISTINCT. The failure row therefore carried no unique guard, and a retried turn appended a second copy of it. ENG-2244 widened the CHECK to (role <> 'user') = (in_reply_to IS NOT NULL). Every row the platform writes is now keyed by the message it answers, and the guard that protects an answer protects a failure too.

What the client renders from

The durable row is the render source, and a frame is a hint that one moved.message.created and message.completed carry the whole row, so the client upserts by id. The other three carry no row: run.started names a run the assistant row already holds in run_id, turn.failed names a reason the system row already holds in text, and stream.lagged names nothing. All three therefore mean one thing to the client, which is to read GET /messages again. One render path then serves a live turn and a reload.

A message is immutable, so the merge is a union by id. No message row is updated after it is written. The span merge keeps the newer updated_at, and this one needs no such test. A page merges into what the client holds, and never replaces it: the read starts after the subscribe, so a frame can land before the page returns, and a replace would drop it.

⚠️ The reconcile runs after the resubscribe, and not on the next frame. The Run stream may defer it, because the server answers a terminal frame off the row on every connect, so a frame always follows the gap. Nothing on this channel is synthesized. A message.completed published inside the gap is the last frame of that turn, so a client that waits for a later frame waits for ever, and the message stays pending. The connection ends at MAX_STREAM_SECONDS, about sixteen times in a working day, so this gap is ordinary.

The client sends one message at a time. The turn queue admits one turn per conversation, so a second message carries no message.created until the first turn ends. The client holds its pending message off the 202 and blocks the next send, so no turn is silent.

⚠️ A repeated Idempotency-Key answers 200 with the first message. The answer is not the text of the second request. A client that renders what it sent shows the wrong words, so it renders what the route returned.

Rules

  • Web chat has no private path around the Front Door.
  • A conversation belongs to one organization. It records its creator, and each message records its own sender.
  • A web chat conversation has one person. A shared channel thread can have more, so every turn uses the actor of the person who wrote that message.
  • conversation_id is the platform identifier. A later Channel Gateway maps an external thread onto it.
  • The route refuses a message over 8000 characters with 400, and it refuses an empty one with 400. There is no gateway in front of it to drop one. The bound is a route bound: agent.conversation_messages.text carries no length check, and it refuses only the empty string.
  • The client sends Idempotency-Key, and the route answers 200 with the message a repeated key already created.
  • The route namespaces the key per caller and per conversation. uq_conversation_messages_client_key is unique on the organization, and one organization holds many people and many conversations. A key namespaced on the caller alone would answer one conversation with the message of another.
  • A duplicate sends the durable event again. The event id is conversation.turn:<message_id>, so Inngest drops the second copy inside EVENT_DEDUPE_WINDOW and no second turn runs. A first send that never landed then recovers on the retry. Without the re-send that message is never answered, and no error says so.
  • A conversation is read by the person who created it. The list filters on the creator, and every route answers 404 for a colleague's conversation. A shared channel thread relaxes this, and the Channel Gateway owns it.
  • An impersonated session reads a conversation and writes none. sender_user_id would name the person acted for, and the turn would spend that organization's ceiling under a name nobody can correct. The Approval Inbox refuses a resolution for the same reason.

Approval Inbox

The Approval Inbox resolves the shared approval row. Policy and governance owns the decision, the expiry clock, the state writers and the re-checks before an approved action runs. Human review inbox owns the product view.

TEXT
GET  /api/v1/agentic/approvals?status=pending&cursor=
GET  /api/v1/agentic/approvals/{id}
POST /api/v1/agentic/approvals/{id}/approve
POST /api/v1/agentic/approvals/{id}/reject

It does not expose resume_run. Resumption is a consequence of resolving the row.

The list

  • The list is scoped to the organization, and status is its one filter.
  • status defaults to pending. The page is the work that waits, and a list of every status reads no index range.
  • One sort key: expires_at ascending, then id. The product asks for the soonest expiry first. id breaks the tie, because two approvals of one parallel node share one expiry.
  • ⚠️ The tie break carries no meaning, and it does not answer "oldest first". id is a random UUID, so two rows of one expiry come back in an arbitrary but stable order. A page that promised the oldest request first would key created_at as a fifth index column, and V1 needs no such promise. Nor is expires_at order the same as created_at order: ApprovalService.create() takes the smaller of the rule's TTL and the run's own deadline, so a later request can expire first.
  • ⚠️ A pending row that expires mid-page is withheld from the rest of that page. Each page reads the clock again, so a row whose expiry passes between page one and page two fails the expires_at > now filter and no later pending page carries it. It is not lost: it moves to the expired list, where the reader finds it. The expired list has no such gap, because expires_at is frozen and every member of that set already satisfies expires_at <= now, so a row joins it after the cursor and never before it.
  • idx_approvals_org_status_expires replaces idx_approvals_org_status_created, which no query reads. It answers the pending page as one ordered range scan. It cannot answer the expired page in order, because that page is two ranges: the planner reads both and sorts the whole set of one tenant before the limit. That is the History tab, and it is read rarely.

The expired state a row derives

A list computes the expired state from expires_at, so the queue never offers a dead row before the wait timeout fires.

TEXT
status=pending   ->  status = 'pending' AND expires_at > now
status=expired   ->  status = 'expired' OR (status = 'pending' AND expires_at <= now)
any other        ->  status = <value>

The second line keeps a lapsed row reachable. Without it a row sits in no list at all between its expiry and the timeout write.

⚠️ One clock answers one request. The API reads its own clock once, and the filter and the response field both take that value. Two clocks let the list and the row disagree at the boundary, and a person then reads pending on a row the queue withheld.

The two writes

  • A repeated resolve answers 200 with the current state.
  • A write refuses one row, and only one: a row that still reads pending and whose expiry has passed. It answers 200 with the current state and writes nothing. A queue that never offers a dead row must not accept a decision on one, or the record says a person authorized work that never ran.
  • ⚠️ Every other row reaches the resolver, and a row already resolved must. The resolver sends the wake event whether or not it wrote, and that second send is the only repair for an event lost between the write and the first send. A route that answered a resolved row from its own read would remove the repair: the run would sleep until its wait timed out, and it would then report that nobody answered after a person had answered. Nothing is written or sent twice. The update is conditional on pending, and the send refuses a status no person wrote.
  • ⚠️ The lapse guard protects the record, and it is not the safety net. The route reads the row, then writes it, and the row can lapse between the two. ApprovalService.authorizes() check 2 still refuses a decision whose resolved_at is later than expires_at, so a lapse inside that window stops the effect. The two rules do not contradict each other: check 2 measures when the decision was made, and the guard measures whether the row was still open when a person pressed.
  • Two concurrent decisions produce one transition. The update is conditional on pending, and the loser reads the winner's state.
  • An impersonated session may read, and it may not resolve. It answers 403. AuthContext resolves the effective user, so resolved_by would name the person acted for, and agent.approvals holds no free text field that names the caller behind them. A human decision that authorizes an effect must name the person who made it.
  • V1 checks visibility alone. The scope of the approver is checked before the effect runs, and not here. See policy and governance.

What the projection carries

  • proposed_arguments holds business data, such as an email body. It follows the Tool result redaction rules, which are a key name rule, so the response redacts a credential and the stored row stays executable.
  • preview is the line a handler rendered. It is text, so no key name rule reaches it.
  • The projection carries no continuation and no idempotency_key. Both are runtime plumbing, exactly as lane and execution_ref are on a run.
  • V1 answers run_id, and no definition name. The row holds no definition, and a join would put the agent.runs shape inside a policy repository. A reader opens the run.

Prospect review

Prospect review is business curation over the Signals Search result. It is not an approval. It creates no agent.approvals row.

The product tables live in public. This surface uses scoped_db(organization_id) instead of the agent schema client.

TEXT
GET  /api/v1/agentic/prospects?review_state=new&cursor=&limit=
GET  /api/v1/agentic/prospects/{id}
GET  /api/v1/agentic/prospects/{id}/people?cursor=&limit=
GET  /api/v1/agentic/prospects/{id}/signals?cursor=&limit=
POST /api/v1/agentic/prospects/{id}/watch
POST /api/v1/agentic/prospects/{id}/dismiss
POST /api/v1/agentic/prospects/{id}/promote

Promotion is not a state patch. The route resolves or creates the CRM records before it writes promoted.

The three pages

  • The prospect list requires one review_state. It defaults to new.
  • Every page orders by created_at descending, then id descending. Both values are immutable.
  • The cursor is opaque. A timestamp tie cannot drop or repeat a row because id breaks it.
  • A row inserted after page one appears before that page's cursor. It appears after a refresh and does not shift the remaining pages.
  • A score or evidence update cannot move a row between pages. A review-state change can remove a row from its old filtered list, which is the intended result.
  • The list, people page and signals page each have an index on organization_id, the parent or state filter, created_at and id in query order.

The projections

The API uses these exact JSON fields. UUID, datetime and decimal values use their normal JSON string form. Every field marked null is present but can be JSON null. Each page returns items and next_cursor: string | null. limit defaults to 50 and accepts 1 through 100.

ProspectSummary contains:

FieldTypeSource
idUUIDprospects.id
company_namestring | nullintel_companies.name
company_domainstring | nullintel_companies.domain
review_statenew | watching | dismissed | promotedprospects.review_state
opportunity_scoreinteger | nullprospects.opportunity_score
opportunity_reasonstring | nullprospects.opportunity_reason
recommended_actionstring | nullprospects.recommended_action
people_statepending | found | no_matching_peopleprospects.people_state
people_state_reasonstring | nullprospects.people_state_reason
crm_company_idUUID | nullprospects.crm_company_id
first_seen_atdatetimeprospects.first_seen_at
last_seen_atdatetimeprospects.last_seen_at
created_atdatetimeprospects.created_at
updated_atdatetimeprospects.updated_at

ProspectDetail contains all ProspectSummary fields plus one company object. That object contains id: UUID, linkedin_url: string | null, website: string | null, industry: string | null, sub_industry: string | null, business_model: string | null, location: string | null, employee_count_exact: integer | null, employee_count_band: string | null, annual_revenue: decimal | null, revenue_band: string | null, revenue_currency: string | null, revenue_year: integer | null, funding_round: string | null, funding_amount: string | null, fetched_cold_at: datetime | null, fetched_warm_at: datetime | null, fetched_hot_at: datetime | null and last_enriched_at: datetime. It embeds no people, signals or sources.

Each people-page item contains id: UUID, prospect_id: UUID, crm_person_id: UUID | null, persona_fit_score: integer | null, persona_fit_reason: string | null, contact_state: unverified | verified | unavailable, first_seen_at: datetime, last_seen_at: datetime, created_at: datetime, updated_at: datetime and one person object. The person object contains id: UUID, linkedin_url: string, full_name: string | null, avatar_url: string | null, current_title: string | null, current_company_text: string | null, location: string | null, country: string | null, email: string | null and last_enriched_at: datetime. It returns no source_ids, provider payload, phone number or profile history.

Each signals-page item contains id: UUID, prospect_id: UUID, signal_score: integer | null, signal_reason: string | null, first_seen_at: datetime, created_at: datetime, updated_at: datetime and one signal object. The signal object contains id: UUID, subject_type: company | person, subject_id: UUID, signal_type: string, description: string | null, observed_at: datetime, ingested_at: datetime and source: object | null. When present, source contains provider: string, ref: string | null and fetched_at: datetime. The API resolves it from the nullable intel_signals.source_id; it does not require an intel_signal_sources primary row. It returns no provider payload, price or corroborating source array.

The global Intelligence tables deny authenticated, but this API reads them with the service role. The root scoped_db query still filters the organization-owned prospect row. The response selects only the fields above.

Watch and dismiss

  • The client sends watch or dismiss as a route intent. It never sends review_state.
  • Either action can move new, watching or dismissed. A repeated action returns 200 and writes nothing.
  • promoted is terminal for these two actions. A conditional update cannot overwrite it, including during a promotion race.
  • Concurrent watch and dismiss requests use the last accepted database write. Each client refetches durable detail after its write.
  • Watching and dismissing create no approval and never call ToolInvoker.

Promote

Promotion is the one route of this surface that writes CRM. It shares one service with the prospect.promote tool, so a person and an agent promote by the same rules.

TEXT
POST /api/v1/agentic/prospects/{id}/promote
{ "person_ids": [UUID], "list_id": UUID | null }
  • The path names the prospect. The body never does, and it names no organization.
  • person_ids holds prospect person ids, not CRM ids. It accepts at most 25, refuses a repeat, and an empty list promotes the company alone.
  • list_id names one static CRM list. The company joins it. The route reads the list before the first CRM write, so a list it cannot use refuses the call before it creates a row.
  • The answer carries prospect_id, review_state: promoted, crm_company_id, one people row for each selection with prospect_person_id, intel_person_id and crm_person_id, and list_id.
  • Every step resolves rather than creates. A second promotion writes nothing and answers the same references. A later promotion that selects a new person links that person and changes nothing else.
  • promoted is written last. A partial attempt keeps the CRM rows and the earlier review state, and a retry completes the missing links.
  • The route passes no policy checkpoint, because a checkpoint belongs to a run. A policy rule on promotion applies on the tool path.

Every outcome has one status

OutcomeStatusMeaning
unreadable cursor400the cursor is empty, malformed or for another order
unusable company or list400the company has no name, or the list is not static and does not accept a company
not found404the prospect, parent prospect, selected person or list is missing or belongs to another organization
already promoted409watch or dismiss cannot overwrite the promotion result
person cannot resolve409a selected person names more than one CRM row, or already holds a different CRM company link
invalid id, state or limit422request validation refused the input
storage fault500the error decorator recorded an unexpected repository failure

Every success returns 200. A page may return no items. A repeated watch, dismiss or promote returns the current result.

Saved searches

A saved search is one repeatable Signals Search brief. It is product state in public, so its repository uses scoped_db(organization_id). It is not the legacy workflow_presets table.

TEXT
GET    /api/v1/agentic/saved-searches?cursor=&limit=
POST   /api/v1/agentic/saved-searches
GET    /api/v1/agentic/saved-searches/{id}
PATCH  /api/v1/agentic/saved-searches/{id}
DELETE /api/v1/agentic/saved-searches/{id}
POST   /api/v1/agentic/saved-searches/{id}/runs       Idempotency-Key
GET    /api/v1/agentic/saved-searches/{id}/diff?cursor=&limit=

The stored brief

Create takes name and brief. Patch takes expected_updated_at and at least one of those two fields. The API trims the name and accepts 1 through 200 characters. The database keeps the unique name rule inside one organization.

The brief is an object. It must contain a non-empty icp string or a non-empty company_criteria array. It must also contain a persona object with at least one value in titles, departments, seniority or country_codes. The API preserves every other JSON field.

Create validates and normalizes the supplied brief. A patch that changes the brief validates and normalizes the resulting brief. Normalization trims the supported ICP, company criteria and persona strings before it applies their length bounds. It preserves unknown top-level fields unchanged. The stored brief and the response contain this normalized copy. A name-only patch does not validate or normalize an unchanged brief. Start normalizes legacy stored input before it validates and freezes the Run input. Thus, a legacy row without a usable persona stays readable and can be renamed or deleted, but it cannot start or change its brief until a patch supplies a usable brief.

The API measures the complete saved-search Run input before create or patch stores a brief. It refuses a brief that makes this input exceed the platform's 32 KiB Run-input limit. A brief accepted by create or patch is startable.

SavedSearchSummary contains id, name, last_run_id, last_run_at, created_at and updated_at. The two Run fields can be null. SavedSearchDetail adds brief. A list never returns every brief.

The list orders by created_at descending, then id descending. Both values are immutable. It returns items and next_cursor. limit defaults to 50 and accepts 1 through 100.

Patch uses updated_at as an opaque write token. The client echoes the exact string. An empty patch writes nothing, so it cannot take another writer's token. Delete carries no token and returns 204.

JSON
{
  "contract_version": 1
}

The start route reads the visible saved search and builds this Run input:

JSON
{
  "source": "saved_search",
  "saved_search_id": "UUID",
  "baseline_run_id": "UUID | null",
  "brief": {}
}

The caller cannot replace the stored brief or name an executor. Initial Run admission calls the shared capability start service for signals.search with the supplied contract version, one frozen input and the caller-scoped key. It uses capability validation and records the normal capability request digest. It returns the same Run detail. A stale version returns 409 contract_version_conflict, and an unavailable binding returns 409 capability_unavailable.

The client key contains 1 to 255 characters. Before any mutable saved-search read, the route uses the shared atomic idempotency plane. The exact claim identity is:

TEXT
organization_id = actor.organization_id
scope           = surface.saved_search.start
key             = "user:" + actor.user_id + ":" + client key
request_hash    = SHA-256(canonical JSON {saved_search_id, contract_version})

The stable capability delivery key uses the actor user ID and client key only. Before the route reads or claims anything mutable, it looks up that Run key. A Run whose capability, version and frozen saved-search ID match returns as a duplicate. Any mismatch returns 409 idempotency_conflict. Search ID and version validate the claimed Run; they do not select another Run key.

The claim's stored response is the normalized brief, saved-search ID and baseline Run ID, not an HTTP response. The winner reads current state, composes the input and completes the claim with that immutable snapshot before capability admission. Only a successful fenced completion may continue to admission. A worker that loses its lease stops.

This preparation scope uses a 30-second lease and 24-hour retention. The lease covers tenant reads, normalization and the snapshot write. A concurrent loser never reads mutable saved-search state. It uses the completed snapshot, or returns retryable 409 start_in_progress with Retry-After: 1 while the lease is live. After the lease expires, exactly one caller reclaims it. The retention matches the browser recovery window. After snapshot retention, an existing Run still returns through its stable Run key for the complete 396-day Run retention window. If neither the snapshot nor the Run remains, a delivery is a new request and reads current state.

A failed winner releases a claim only when it froze no snapshot and started no work. The capability delivery key does not depend on the claim ID or mutable input. Thus, every delivery of the endpoint identity reaches one Run key.

During the 24-hour snapshot window, a later delivery sends the same input through normal capability digest replay. After that window, the stable Run key returns an admitted Run directly. Neither path recomposes an admitted Run's request from mutable state. After the Run retention window, the delivery reads current state as a new request. Reusing the key with another search or contract version returns 409 idempotency_conflict while the snapshot claim or Run remains.

The CLI command is ac agentic saved-searches start SEARCH_ID --contract-version VERSION --idempotency-key KEY. It does not accept --definition. Any CLI plugin recipe that starts a saved search uses the same stable arguments.

The input is a snapshot. baseline_run_id is the saved search's current last_run_id when the Run starts. A later saved-search edit or completed Run cannot change either snapshot value. Deleting the saved search does not cancel that Run. The Run still holds its input, but a later product write can find that the search no longer exists.

The route creates no Trigger, schedule, queue or product runtime. Phase 5 can bind a Trigger to the same published workflow.

This input requires the normalized Signals Search source step. The published workflow must start with signals.compile_search_scope, and later nodes must read its frozen as_of, brief and persona. A definition that still reads input.as_of or input.persona cannot run a saved search and must not enable Smart Feed publication.

The latest Smart Feed diff

The diff route reads saved_searches.last_run_id. It returns the diff of that latest published successful Run. If no Run has published, it returns run_id: null, an empty items array and no cursor. A published Run with no material change returns its Run id and the same empty page.

Only a Run whose durable status is succeeded can replace this pointer. The last workflow node is compile-smart-feed-observations. It reads all named prospects, signal grades and attached people in one database statement. The same snapshot checks that every prospect still names this Run in last_seen_run_id. A mismatch returns superseded. A ready result holds one digest envelope per prospect and the scope step's frozen as_of observation time. The node moves no pointer and writes no publication row.

The workflow selects the node output under RunResult.output.smart_feed. It does not store the eleven earlier node outputs in the completed Run result. A later Run can change mutable prospect state, but it cannot change this Run result. After run.execute finalizes the Run, its publish.succeeded step sends agent/run.succeeded with only the organization and Run ids. The stable event id is run.succeeded:<run_id>. The consumer reads the Run again. It requires a saved-search input, no partial_reason and a schema-valid, ready smart_feed output. A missing or dropped projection publishes nothing.

The consumer does not reject the global RunResult.truncated flag alone. RunManager bounds the selected output before it bounds resource refs, so that flag can mean that only the diagnostic refs were shortened. The smart_feed value is one top-level item: it is either intact or replaced by a Dropped marker. Strict projection validation distinguishes those cases.

The writer first records immutable rows in saved_search_run_prospects. Each row names the Run, prospect, observed digest, change reasons and first and last seen times. It records every prospect the Run observed. An unchanged prospect has an empty change_kinds array. Thus, the next Run can compare with the complete published result instead of its diff page.

The table carries organization_id, saved_search_id, run_id, prospect_id, observed_digest, change_kinds, first_seen_at, last_seen_at, created_at and id. Composite foreign keys keep the search and prospect in the same organization. Organization-scoped RLS applies. A partial index on (organization_id, saved_search_id, run_id, created_at DESC, id DESC) covers rows whose change array is not empty.

The rows are append-only. The unique key is (saved_search_id, run_id, prospect_id). A retry uses insert-on-conflict-do- nothing and then verifies that an existing row has the same immutable values. It never updates or deletes an observation.

observed_digest is UTF-8 canonical JSON text. Serialization uses sorted keys, comma and colon separators with no added whitespace, unescaped Unicode, JSON null for null values and lowercase UUID strings. It refuses non-finite numbers. Text is stored text and gets no second trim. The complete version 1 shape is v, score, signal_count, signals_sha256, people_count, people_sha256 and evidence_sha256. Each SHA-256 value is lowercase hex.

The signal hash reads tuples of Intelligence signal id, signal score and signal reason, ordered by signal id. The people hash reads tuples of Intelligence person id, persona-fit score, persona-fit reason and contact state, ordered by person id. The evidence hash reads company name and domain, opportunity reason, recommended action, people state and people-state reason. Each hash input uses the same canonical JSON rule. An unknown digest version stops publication instead of guessing.

The first observation absent from the baseline gets only new. For an existing baseline, any score change gets score_changed. A larger signal count gets new_signals, and this reason explains the signal hash change. A larger people count gets new_people, and this reason explains the people hash change. A same-or-smaller count with a changed collection hash gets evidence_changed. A changed evidence hash also gets evidence_changed. Reasons use this fixed order: new, new_signals, new_people, score_changed, evidence_changed. Any exact normalized value change is material. There is no score threshold.

The Run input freezes the comparison baseline. Publication compares saved_searches.last_run_id with that baseline_run_id. If they still match, one compare-and-set writes the Run id and ended_at. If they do not match, another Run already published and this Run is stale. A stale Run remains in Run Explorer but never replaces the Smart Feed. Thus, overlapping Runs cannot replay one baseline's changes.

Publication is a durable, idempotent job after Run success. It retries until the compare-and-set publishes the Run or proves it stale. A retry that finds the pointer already on its Run still finishes membership. A failed, cancelled, missing-projection or pre-promotion partial Run publishes nothing. A Run whose only truncation is in resource refs can still publish its intact projection.

A later Run can start after the pointer moves but before membership finishes. Before it writes, it reads membership for the union of baseline and current prospects. If a baseline prospect has no membership, it repairs that row from the immutable baseline observation before it writes its own immutable rows. The baseline Run id supplies both Run fields. The observation supplies the first and last seen times. This repair also covers a baseline prospect that the later Run omits and a future Run returns. Thus, no terminal consumer failure can block later Runs or freeze incomplete first-seen values.

There is no periodic reconciler. The event send is an Inngest step, so Inngest retries it. If the producer still exhausts its retries, the search keeps its old pointer. Its next Run freezes the same baseline and publishes the current cumulative change. If the consumer exhausts retries after the pointer moves, the next Run repairs missing membership from that immutable published result. A product cron and an index on generic Run input would add a second recovery path without a stronger product guarantee.

saved_search_prospects keeps only search-wide membership: the first and most recent successful observation of one prospect. A new immutable observation uses the membership first_seen_at, or its own observation time for new membership. Its last_seen_at is the frozen observation time. It never copies the organization-wide times from prospects.

An idempotent publisher updates membership after the pointer. A newer last_seen_at wins. An equal time must name the same Run and values. An older observation writes nothing. It does not hold a digest, Run diff marker or change reasons. Those values are per-Run facts and belong only to saved_search_run_prospects.

Each item contains change_kinds, first_seen_at, last_seen_at and one current ProspectSummary. It returns no reported_digest. The writer decides material change and records the reasons. The reader never recomputes them.

The page reads rows for last_run_id whose change_kinds is not empty. It orders them by immutable created_at and id, newest first. Its cursor also binds the page to last_run_id. If another Run becomes latest between pages, the next request returns 409 and tells the client to restart. It never combines two Run diffs.

This is not a historical diff API. The route accepts no Run id. Immutable rows exist so a failed or overlapping Run cannot damage the published diff; their retention is an internal storage rule, not a product promise. Run Explorer is the product Run-history surface.

Every outcome has one status

OutcomeStatusMeaning
unreadable cursor400the cursor is empty, malformed or for another page
unusable brief or empty patch400the stored search could not start, or the patch names no field
not found404the saved search is missing or belongs to another organization
duplicate name409this organization already has the exact trimmed name
stale update409expected_updated_at no longer matches
diff changed409a newer Run became the latest while the client paged
start in progress409another delivery of this key is freezing the input; retry with the same key after Retry-After
input too large413the composed saved-search Run input is over 32 KiB
invalid id, name or limit422request validation refused the input
storage fault500the error decorator recorded an unexpected repository failure

Create returns 201. Read, list, patch, start and diff return 200. Delete returns 204. Start also uses every Run start outcome in the Run Explorer table; this section does not copy or change that closed set.

Product capability surfaces

Phase 7 adds five feature-gated product routes. Their shared capability contracts supply field definitions and limits.

RouteCapabilityResult handoff
/companies/searchcompany.searchSelect company refs for Enrich or Find People
/companies/enrichcompany.enrichRead frozen company results and canonical refs
/people/searchpeople.searchSelect person refs for Enrich
/people/enrichpeople.enrichRead profile results and requested email state
/signals/searchsignals.searchOpen the existing saved-search or prospect review context

Companies and People each present Search and Enrich segments. Signals remains the opportunity-discovery entry point. Keep /crm/companies, /crm/people and /prospects available. Phase 7 does not rebuild those pages or perform legacy cutover. The shell owns typed API access, server validation mapping, start keys, Run status and selection handoff. The server validates the published JSON Schema. The shell does not add a second schema engine. Product views own their forms, result rows and diagnostics.

Capability API and CLI

All paths below use the /api/v1 prefix. All three ship on the runtime baseline.

Method and pathCLI commandContract
GET /agentic/capabilitiesac agentic capabilities listThe whole authorized catalogue; no cursor and no limit; available_only defaults to true
GET /agentic/capabilities/{capability_id}ac agentic capabilities getOne authorized product contract, including unavailable state
POST /agentic/capabilities/{capability_id}/runsac agentic capabilities startVersioned input and required Idempotency-Key; delegate to RunManager

The list is not paginated, and it is the second route on this surface that is not. The vocabulary holds five IDs and a deploy fixes them, so a page can never fill. GET /agentic/tools answers a build-time-fixed catalogue the same way, and the shared cursor encodes a (timestamptz, uuid) pair that a capability ID is not. A list therefore returns {items} in stable capability-ID order, and it takes no limit and no cursor. Read replies omit executor UUIDs, configuration and provider Tool definitions. The public five-ID vocabulary reveals no tenant bindings. An available record carries required_scopes. The registry answers a contract only to a caller that holds every scope in that set, so the field states rights the caller already holds. The server checks run.start before the binding lookup, and the published required scopes after it. The list returns 200 for every authenticated member, including one that holds no run.start. available_only filters every unavailable record, unauthorized included, so that actor reads zero items under the default. available_only=false returns the five records it would have hidden, each with its own reason. On the single-ID endpoint an unknown ID returns 404, and the unauthorized reason returns 403. The three remaining reasons return 200 and carry the reason, so an authorized uninstalled, disabled or misbound product stays readable with availability: unavailable. The two routes therefore split one reason and share the other three. A batch read cannot answer one status per member, and 403 is the answer a single read owes a caller that may not start it. An unavailable start returns 409. The registry never silently chooses another executor.

The start body is {contract_version: 1, input: {...}}. A schema error returns 422 with a field path. Oversized input uses the existing 413 response. A stale contract version returns 409 contract_version_conflict; a changed request under the same key returns 409 idempotency_conflict. The shared capability start service uses the start identity contract. RunManager records the request digest in the frozen snapshot with its atomic Run insert. Chat, API and CLI use this service; clients do not implement a separate version or replay rule. Caller identity namespaces the start key. A capability-start prefix separates it from generic definition starts. Unknown, unavailable and unauthorized starts return 404 capability_not_found, 409 capability_unavailable and 403 capability_unauthorized, respectively. The CLI requires CAPABILITY_ID, --contract-version, --input and --idempotency-key. It supports --json.

Successful admission and replay return the existing Run detail with HTTP 200 and outcome: started or duplicate. Capability identity and contract version are fields on that Run. The normal Run endpoints own status, spans, cancel and streaming. Policy denial can produce a failed Run and approval can produce a waiting Run. Read its status, not just HTTP 200. Budget refusal retains the existing 429 and Retry-After behavior. Generic custom-definition starts keep their current endpoint and replay behavior; the stricter digest rule belongs to capability starts.

Shared launch shell

ENG-2284 owns the common client, state and shell. Product tickets supply forms, result rows and canonical entity reads. Enable the five routes and their navigation with VITE_ENABLE_CAPABILITY_PRODUCTS=true. The default is off. The same gate controls route registration and navigation. Existing authentication guards still apply. The gate controls rollout. The API checks permission on every request. With the gate off, direct product URLs resolve to the existing not-found page. Legacy routes keep their current behavior. The initial route shell shows product navigation and availability. It has no generic JSON editor or automatic start.

Read the catalogue with available_only=false to retain unavailable reasons. Do not request a cursor or a limit. Use capability_id, not the internal registry field id. An unavailable record has only the ID, availability and reason. Render loading, failed reads and empty replies as different states. A read failure is not an unavailable capability. Map server schema paths and envelope validation paths to field errors. Do not show rejected input values. A version conflict requires a fresh contract read and an explicit start with the accepted version. A permission, availability or budget refusal retains the draft. A retry never refreshes the key by itself.

Keep one unresolved attempt per capability in same-tab session storage. Scope it to both organization and actor. Freeze the capability ID, version, input and key before POST. Reject input that is not JSON or exceeds 32 KiB. Persist before POST. If storage fails, refuse the launch so a reload cannot lose the recovery key. A double click sends one POST. A timeout, connection fault or 5xx leaves the attempt unresolved. An explicit retry sends the same frozen request and key. A reload restores the attempt but sends no POST. Do not replace an unresolved attempt with edited input. Resolve it first so an earlier Run cannot be hidden. A definitive refusal of the first request ends that attempt. A later explicit start may use a new key. After an uncertain response, a later refusal does not prove that the first request failed. Keep its key even after a rate-limit or authorization refusal. Middleware can refuse a retry before the replay lookup. A response-body failure after HTTP 200 is also uncertain. Retry that attempt with its original key. An admitted Run keeps its ID across reloads. Reopening it reads the Run and never starts another one.

Subscribe before the initial Run read. Stream frames are hints; the durable Run owns the displayed status and output. Re-read on a stream gap, reconnect, root terminal event or explicit refresh. Use a single bounded polling interval while following a nonterminal Run. This also covers quiet streams and failed connections. Serialize refreshes and coalesce repeated hints. An older response cannot replace newer state. A child terminal event does not end the root. A root terminal state never returns to a nonterminal state. Stop streams, timers and reads when the view leaves or the actor changes. Ignore all late responses from the old scope. Clear visible state and selections on identity changes. Keep recovery records under their original actor and organization until the tab closes. Returning to that scope can restore its attempt. Another actor cannot load it through the shell. Run Explorer owns the span tree and approval actions. Product adapters own canonical reads and result diagnostics. The shell exposes durable Run output and canonical refs without copying product result tables or inferring email verification.

Company Search journey

ENG-2290 supplies the form and result table at /companies/search. Reuse the shared shell and actor-scoped selection store. Do not add a second launcher, schema engine or selection store. The form supports the published V1 sources: supplied companies, Explorium, or both. Start with supplied companies selected. Never enable a paid source without an explicit user choice. Accept one domain or organization LinkedIn URL per line. Canonical and saved-row refs remain typed handoff inputs. Do not parse CSV, infer companies from names, or rewrite identity values in the browser.

Read source choices, country codes, employee bands, revenue bands and count limits from the published input schema. Use local schema references for field metadata. Missing required metadata or an unsupported contract version disables new starts. The current V1 generator puts a non-standard maxLength annotation on some array schemas, not on their string items. Read standard maxItems first and this annotation as a compatibility fallback. Standard JSON Schema validators ignore that array annotation. The executor still enforces array bounds. Use the published target default, which is 50 in V1. Do not clamp an invalid entered count silently. Industry names use the provider vocabulary. Accept names as text; do not reuse the CRM industry picker or duplicate the provider list. The executor validates provider industry names. A runtime input refusal can therefore produce a failed Run. The shared start endpoint validates JSON Schema, which does not encode every executor cross-field rule.

Omit blank filter lists. Require one supported filter when Explorium is selected. Require 1 to 100 company refs with the supplied source; omit companies for provider-only requests. Use OR within a filter and AND across filters. Unknown facts can remain in results; explicit contradictions are excluded. Keep the form draft after refusal and availability refresh. Restore the frozen request when reopening a saved attempt. Clear the visible draft on actor, organization or impersonation changes. An uncertain attempt locks edits until retry resolves it. A new Run requires an explicit start after the current Run ends.

Read the typed envelope from RunResult.output.company_search, not from output.items or top-level output.outcome. Check the capability ID, contract version and row identities before enabling selection. Keep Run status separate from result outcome. A failed or cancelled Run never appears as an empty successful search. A partial result can contain zero rows. Missing or invalid output after success is a result error, not an empty search. Show the funnel, requested source states, usage, diagnostic reasons and omitted diagnostic count. Label absent public facts as unknown. Never infer enrichment or CRM membership from a canonical Intelligence ID. Retain a stable form frame and minimum result area during loading. Allow result-table scrolling within the page on mobile.

Store selected rows as {kind: "search_result", run_id, result_id} refs, in selection order, with at most 100 entries. The frozen row retains its normalized identity and optional canonical ID. Do not put either display data or refs in the URL. Restore selection only for rows in the currently displayed successful Run. Duplicate status reads do not change selection. A newly admitted search clears the prior company selection. A refused or uncertain start retains the prior selection. A failed selection write shows an error and retains the previous saved selection. ENG-2288 owns the Enrich handoff action. ENG-2292 owns Find People. Neither action starts work in ENG-2290.

Company Enrich journey

ENG-2288 supplies the form and frozen result details at /companies/enrich. Reuse the shared launcher and company selection store. Add no API, second launcher or provider path. Search offers an explicit Enrich selected action only when valid rows are selected. Navigation carries no entity list or display data in the URL and starts no work.

A fresh form prefills the shared selection. A saved Enrich attempt takes precedence over a new Search selection. Restore its frozen input without a POST. Lock edits while that attempt is unresolved or its Run is active. After it ends, offer Use search selection as an explicit replacement. Do not append new refs to the restored request. Keep selection changes local to the Enrich draft. Do not change the saved Search selection from this form. If no selection exists, show direct entry and a link back to Search. Direct entry accepts domains and canonical company UUIDs in separate text fields, one value per line. The form sends domains first, then canonical refs, in line order. It does not infer identity from company names. Keep restored LinkedIn and search-result refs typed. Never reconstruct them from display text.

Read the subject bound, preset choices, field sets and refresh defaults from the published V1 schema. Use Basic and stale defaults. Omitted fields mean the whole preset; an explicit subset must not be empty. Changing a preset selects its full field set visibly. The user can then choose a smaller subset. Explain missing-only refresh: it keeps existing stale values without verification. Require 1 to 100 subjects and a separate explicit start. Preserve duplicate input indexes through server resolution. Explain the separate output byte limit. Never split, truncate or retry a batch automatically.

Read RunResult.output.company_enrich. Validate its capability, version, rows and indexes against the frozen Run input. Keep Run status, product outcome and field states separate. A retained value does not prove a successful refresh. Show every selected value and its reported field state, including retained, not found, pending, failed and cancelled. Show the reported cache decision and tier times. A not-read cache is not a cache miss. Show bounded diagnostics and lineage omission counts. Do not invent per-field provenance. Show frozen estimated or settled usage once. If output is absent, show the available root meter without inventing a settlement state. An unavailable meter is not zero cost. Payload failure can follow completed writes and charges; state that limit. Keep the result frame stable and all details reachable on desktop, mobile and keyboard.

This slice displays frozen company details and canonical refs with the existing Run Explorer link. It does not read current canonical facts or link an Intelligence UUID to a CRM company. The current Intelligence drawer requires superadmin access. Do not reuse it for member access or relax its guard. Live member-facing canonical details need a separate read contract and are deferred. The journey cases EJ01-EJ42 define validation.

People Search journey

ENG-2292 supplies the form and frozen results at /people/search, behind the shared capability gate. Reuse the launch shell, company brief controls and actor-scoped selection store. The form supports supplied people, Explorium, or both. Start with supplied people only. Provider discovery requires an explicit source choice. Show its provider-credit cost warning. Accept person LinkedIn URLs and canonical person UUIDs in separate fields, one value per line. Keep saved search-row refs typed. Do not accept CRM person IDs or infer identities from names.

Offer no company scope for supplied-only requests, known companies, or a structured company brief. Known scope accepts 1 to 20 CompanyRefs. Keep company selections ordered and require explicit replacement of an existing draft. Find People on Company Search accepts 1 to 20 selected rows. Larger selections show a limit message; never truncate them. A fresh People Search form prefills that selection. A saved People Search request takes precedence. After the saved attempt ends, Use company selection explicitly replaces the scope. It does not change the saved company selection. Navigation and scope replacement never select a paid source or start work. A company brief uses the full published Company Search input. It is not a free-text prompt. Reuse the Company Search fields for supplied companies, Explorium filters and target count. The server resolves the brief, bounds it to 20 companies and reports truncation. Show these diagnostics without repeating discovery.

Read source choices, count bounds, text lengths and country codes from the published V1 schema. Resolve local schema references and union branches for metadata only. The server remains the schema authority. Omit empty optional lists and scope fields. Require at least one persona criterion. Persona uses titles, departments, seniority and person country codes. Values within a list use OR; fields use AND. Departments and seniority are text fields because the schema supports supplied-only free text. Explorium checks its narrower vocabulary at execution. Explain this limit; do not copy its vocabulary into the frontend. Use the published target-per-company default and bounds. Supplied-only requests do not use that target to cap their supplied rows. Unsupported metadata disables new starts. A refusal retains the draft; unresolved or active attempts lock edits. Restore a frozen request without a POST. Clear visible drafts on actor, organization or impersonation changes.

Read RunResult.output.people_search. Validate capability, version, identity, bounded rows and per-company diagnostics before selection. Keep Run state separate from result outcome. A succeeded partial search can have zero rows. Missing, dropped or invalid product output is a result error. Failed and cancelled Runs cannot provide selectable rows. A valid partial envelope with output_truncated retains selectable identities after optional facts are trimmed. The global RunResult.truncated flag alone does not reject an intact envelope; resource refs can also set that flag. Show name, title, employer, person country, LinkedIn identity, evidence sources and optional canonical ID. Absent facts remain unknown. Unknown persona fit is not verified fit. A canonical ID does not prove CRM membership. Show the people funnel, per-source states, per-company counts and reasons, usage and omitted diagnostic count. Do not turn evidence URLs into links without an HTTP or HTTPS check. Render external text as text. Keep the form and result frame stable. Contain horizontal table scrolling on mobile and retain keyboard selection focus.

Save up to 100 ordered {kind: "search_result", run_id, result_id} person refs in the existing store. Restore only refs present in the displayed successful Run. Repeated reads preserve selection order. A newly admitted People Search clears prior person selection. Refused or uncertain starts retain it. A storage failure keeps the prior saved selection and shows an error. The People Enrich segment preserves these refs without starting work. ENG-2289 owns its form and Enrich selected action. The journey cases PJ01-PJ40 define validation.

People Enrich journey

ENG-2289 supplies the form, profile preview and frozen results at /people/enrich. Reuse the shared launcher, person selection store and Company Enrich form patterns. Add no endpoint, schema engine, provider poller or second selection store. People Search offers Enrich selected only when valid rows are selected. Navigation starts no work and carries no person data in the URL.

A fresh form uses the shared Search selection. A saved People Enrich attempt takes precedence. Restore its frozen input without a POST. Lock edits while the attempt is unresolved or the root Run is active. After the Run ends, let the user replace the draft with the saved Search selection. Do not change the Search selection from the Enrich form. Direct entry accepts person LinkedIn URLs, canonical person UUIDs and CRM person UUIDs in separate fields. The form sends LinkedIn refs, then canonical refs, then CRM refs. It preserves line order within each field. Keep restored and selected refs typed. Do not copy CRM facts, infer names or rebuild search refs from display data.

Read subject bounds, presets, fields and refresh defaults from the published V1 schema. Use Basic and stale as defaults. Omitted fields mean the complete preset. Changing the preset selects its complete field set. The user can then select a nonempty subset. Require email when email_source or email_score is selected. Explain that Standard can start asynchronous work-email discovery. Require 1 to 100 subjects and a separate explicit start. Do not split, truncate or retry a batch automatically.

Read the root Run as the lifecycle truth. Stream frames only request another durable read. While the root is live, read its one native email child through the Run API. The child must be the root's only direct workflow child and must carry only input.profile. Validate that profile envelope against the root's frozen request before display. Show it as a profile preview, not as the final result. Do not read span payloads, poll provider jobs or call the superadmin Intelligence API.

Read the root's final RunResult.output.people_enrich when it is valid. Require a people.enrich V1 root and frozen input, plus the same identity in the output. Reject truncated output. Validate all row refs, values, states, diagnostics, counts and bounds against V1. Each row must contain every selected field state and no unselected value or state. Current, retained and refreshed states require a value. Input indexes must be nonempty per row and cover every input subject exactly once, without gaps or duplicates. Use only these checked indexes to resolve CRM links. It replaces the profile preview and remains frozen after later Intelligence changes. Reject a final result that still marks a selected field as pending. If the root ends without final output, keep a valid preview and derive unfinished email states from the root status. Map pending email to cancelled for a cancelled root and failed for any other terminal root. This display rule does not change the stored snapshot. A reload can read the same saved child. Do not claim that a final result exists. If no valid preview exists, show the terminal root state only. Keep root status, product outcome, profile state, email operation state and verification status separate.

Show every selected field and its reported state. Pending, failed and cancelled describe the current operation even when a retained value exists. Show email verification only for the exact email named by the metadata. Use unknown when evidence is absent. Reject malformed or mismatched evidence in a final result or preview. Never infer verification from a score or Run success. Show the reported cache decision, tier times, diagnostics, lineage omissions and frozen usage. Do not add root and child usage totals because both describe one Run tree.

Link an explicit crm_person input ref to its CRM detail route through the result's input indexes. Show canonical UUIDs as references only. A canonical UUID does not prove CRM membership. Allow safe HTTP or HTTPS profile links. Render all other external values as text. The existing Run Explorer link remains available for every admitted Run. Keep the result frame stable and all controls reachable on desktop, mobile and keyboard. The journey cases PEJ01-PEJ40 define validation.

Signals Search journey

ENG-2294 supplies the direct launch and saved-search workspace at /signals/search. Reuse the shared launch shell, Prospect Review and saved search API. Add no generic JSON editor, second Run client, provider client or prospect table.

Direct launch has two source modes: discovery and company set. Saved monitoring uses the saved-search start route and is not a third editable direct mode. The discovery mode sends no company refs. The company-set mode accepts 1 to 10 domains, organization LinkedIn URLs, canonical company UUIDs or prospect UUIDs in separate fields. Preserve type and order. Do not accept a company name as an identity. Switching modes omits every hidden source field.

Both direct modes use one brief editor. Label icp as the target-company and signal thesis because the current workflow reads both purposes from this text. The editor also supports up to ten company criteria. Each criterion contains 1 to 200 characters and a required flag. Require an ICP or one criterion. The persona editor uses titles, departments, seniority and country_codes. Require at least one value. Each supplied list contains 1 to 20 unique values and uses the shared field limits. Use the existing country options. Preserve every rejected draft. Do not add separate signal-theme or result-limit fields until the Run contract supports them.

Keep the unsaved draft unchanged while validation or a request is pending. Build a normalized request copy by trimming the supported brief and persona strings before applying bounds. After a saved create or brief patch succeeds, replace the draft with the normalized detail returned by the server. Direct start and saved-search storage therefore apply the same character boundaries.

Read the supported version and schema from the capability record. The product adapter may render only the documented V1 fields. A missing field, different version or incompatible constraint disables start. The server remains the validation authority. Use its bounded field paths in the form.

After a direct Run is admitted, open /prospects?run={run_id}. Do this only for the current explicit start response. A restored, replayed or already admitted attempt stays in the shell until the user opens Prospect Review. A refusal or uncertain response stays on the form. Prospect Review owns live status, empty, partial, failed and cancelled outcomes. Signals does not interpret the final Smart Feed output as a second result table.

The saved-search workspace lists summaries and reads one selected detail. Put only the saved-search ID in the query string. Create and edit use the same brief editor. Preserve unknown stored brief fields when the user changes documented fields. A name-only edit omits brief, so an incompatible legacy persona can still be renamed. A brief edit must correct the complete persona. Never drop or guess a legacy criterion.

Patch sends the exact updated_at token. A stale update keeps the draft and offers an explicit reload. Delete requires confirmation and never cancels an admitted Run. A saved-search start freezes its ID, capability version and key before POST. It uses the same uncertain-response rules as the shared shell. Stay in the saved-search workspace after admission and show the durable Run status with an explicit Prospect Review link.

Show the latest diff separately from the saved-search detail. Distinguish no published Run from a published Run with no material change. Bind every diff page to its returned Run ID. On 409, discard the old page cursor and offer an explicit reload. A diff item opens /prospects/{prospect_id}?run={run_id}. Keep watch, dismiss, promotion and the existing prospect drawer unchanged.

Clear drafts, selected saved-search state, diff pages and visible Runs when the actor or organization changes. Ignore all late responses from the old scope. Keep controls reachable by keyboard and on a narrow viewport. The journey cases SJ01-SJ48 define validation.

Selection and progress

Carry an ordered set of at most 100 stable refs between Search and Enrich. Never put provider payloads, private fields or entity lists in query strings. Use tenant-scoped session state for same-tab handoff. Retain source Run/result IDs so a reload can resolve the selected rows. If the state is absent or a ref is stale, ask for selection again. Do not launch an empty or guessed request. Clear selection state on organization change and ignore late responses for the previous organization.

The Enrich form shows the selection and preset before a separate explicit start. Find People carries the selected company refs into the known-company scope form; it never starts automatically. Preserve a start key while a request is unresolved. A retry or stream reconnect must not issue a new start key. After a user edits the input or accepts a new contract version, an explicit new start uses a new key.

Render running, empty, partial, failed and cancelled states from durable Run and result data. Use an existing detail view only when it accepts that ref and the caller can access it. Company Enrich shows frozen details in this slice; live canonical details are deferred. Search success does not mean enrichment or CRM promotion occurred. The email view distinguishes profile ready, email pending, not found, verification unknown, unverified, verified, failed and cancelled. Render the result's email_verification metadata; never infer verification from a confidence score or a successful Run. Keep the root live while its email child waits. Read the profile preview from the child's immutable input. On reconnect, refetch durable root and child Run data. Duplicate reads must not add duplicate rows. Keep the final frozen Run output after canonical Intelligence changes.

Backend exits prove API/CLI parity. Front Door exits prove selection and refusal. Browser exits prove desktop/mobile layout, selection continuity, failure recovery and async state. The scenario matrix names each owner.

Agent Builder

Agents, Workflows and Skills share one lifecycle, one service, one repository and one validator. They therefore share one API resource.

draft -> validate -> current published configuration

There is no definition revision or history subsystem in V1.

TEXT
GET    /api/v1/agentic/definitions?kind=&origin=&state=&cursor=
POST   /api/v1/agentic/definitions
GET    /api/v1/agentic/definitions/{id}
DELETE /api/v1/agentic/definitions/{id}                a draft only
PATCH  /api/v1/agentic/definitions/{id}/draft          expected_updated_at
POST   /api/v1/agentic/definitions/{id}/validate
POST   /api/v1/agentic/definitions/{id}/publish        expected_updated_at
POST   /api/v1/agentic/definitions/{id}/disable
POST   /api/v1/agentic/definitions/{id}/enable
POST   /api/v1/agentic/definitions/{id}/fork

GET    /api/v1/agentic/tools                           no cursor

GET /tools answers the picker an author selects tool_ids from. It is author facing. The full catalogue never reaches a model. A row carries the dotted platform name, and the tool adapter derives the model facing name from it.

A row carries five fields: name, description, input_schema, output_schema and side_effects. It drops binding, which names the handler key. It drops timeout_s and item_kind, which the invoker reads and an author does not.

build_platform() is the one builder, and AgenticPlatform.registry publishes what it built. Each process holds its own object, because the two run as separate dynos. They therefore answer one catalogue while they run one release. A rolling deploy that changes a declaration is the window where they disagree.

GET /tools is not paginated. The registry is a process-local set that only a deploy changes, and a picker filters the whole of it. The rows are ordered by name, because the registry is keyed by name and holds no order of its own.

disable and enable are a pair. The table below tells an admin to use disable to prevent new Runs, which reads as reversible, so the route that reverses it must exist. enable revalidates before it returns the row to active, because a definition it references may have been disabled while it was off.

DELETE accepts a draft only. A published definition is disabled, never deleted: runs_definition_fk restricts, and a Run's audit trail must not be deletable from under it. Without this route an abandoned draft has nothing that clears it, and conversational authoring creates one per attempt.

OutcomeStatusMeans
stale409expected_updated_at did not match. Reload and retry
invalid422validation failed. The body names every error
definition_in_use409disable refused; an active definition references this one
referrer_limit409publish refused; it would exceed the direct referrer cap
not_a_draft409delete refused; disable it instead
not_published409disable or enable refused; the definition has never published
forbidden403the actor lacks definition.publish
not_found404no such definition for this organization

kind is agent, workflow or skill. origin is platform or custom.

Rules

  • Organization admins author custom definitions.
  • The list returns platform templates too, because a fork starts from one.
  • A platform template is read-only. fork copies it into the organization as a draft.
  • A draft may be incomplete. Publish runs full deterministic validation.
  • PATCH .../draft and publish both carry expected_updated_at. A stale writer reloads. It never overwrites another admin.
  • A draft patch replaces each named field. It never merges into one, because a merge cannot remove a tool.
  • A new Run uses the current published configuration. An existing Run keeps its frozen snapshot.
  • A definition cannot be disabled while an active definition references it. The API returns definition_in_use.
  • disable and enable are idempotent. A second press answers 200 and writes nothing.
  • A custom definition references definitions of its own organization only. fork a platform template before referencing it.
  • expected_updated_at is an opaque string. A client echoes it back unparsed. A JavaScript Date round trip truncates it to milliseconds and every write then answers stale.
  • It travels in the request body, on both routes that carry it. PATCH .../draft already has a body, and publish takes a body of that one field. A header would need a name of its own, a second 400, and a second place a client looks for it.
  • An empty PATCH .../draft body is 400. A BEFORE UPDATE trigger moves updated_at on a write that changes no value, so a patch of {} would take every other admin's token and change nothing.
  • DELETE answers 204. The row is gone, so there is nothing to read back.
  • fork carries no Idempotency-Key, and two calls mint two copies. The shared rule asks for a key on a route that starts a Run, and this route writes drafts. A draft cannot run and it deletes cleanly, so a repeated fork leaves rows an admin removes rather than work that ran twice.
  • The list is a union, and the two halves filter differently. It returns the caller's own definitions in every state, plus the platform templates that are active. RLS does not run on this path, so the read applies state = 'active' to the platform half itself. It pages on (created_at, id), so it is newest first and not alphabetical: name is not unique, and a cursor on it needs an encoder of its own.
  • GET .../definitions/{id} answers 404 for a platform template. A template is visible through the list and through fork, and the list row carries the id, the kind, the name, the origin, the state and updated_at. An admin who wants the configuration forks it, and a fork deletes.

Disable and in-flight work

Disable is not a stop button. Be precise about this in the UI.

To do thisUse this
Prevent new Runsdisable the definition. enable reverses it
Stop a Run that is already executingPOST /runs/{id}/cancel
Stop one action everywhere, immediatelyThe Tool kill switch, which is read live

disable refuses a new Run tree. A workflow already running still starts its child Runs, including one that begins after a three day wait. That is deliberate, and it is why the emergency lever is the tool kill switch and not this. An admin who must stop one Run cancels it.

Run Explorer

TEXT
POST   /api/v1/agentic/runs                            Idempotency-Key
GET    /api/v1/agentic/runs?root_only=true&parent_run_id=&definition_id=&status=&cursor=
GET    /api/v1/agentic/runs/{id}
GET    /api/v1/agentic/runs/{id}/spans?since=&cursor=
POST   /api/v1/agentic/runs/{id}/cancel
GET    /api/v1/agentic/runs/{id}/stream

POST /api/v1/agentic/runs starts a custom Agent or Workflow by definition_id. The capability endpoint resolves a stable product ID before building the same StartRunCommand. Both endpoints, the Front Door and Triggers share one RunManager boundary.

The body carries no parent_run_id, and the route refuses one. start() sends the dispatch event only for a top level Run, so a child accepted here would be created and never claimed, and the reaper would fail it two minutes later. A child Run is invoked by its parent node and reaches no API. parent_cancelled is therefore unreachable from this route.

Run detail returns the product status, what it waits on, the result or error, the pending approval, and the summarized usage. Usage comes from the canonical meter. V1 needs no separate per-Run usage endpoint.

Run detail embeds no child list. A workflow parent has as many children as its widest node, and this page's own case is five hundred. The children are a page of the list route, GET /runs?parent_run_id={id}&cursor=, so the pagination rule holds and no route is added. Detail carries child_count only.

Usage is read from one indexed column, never from a list of span references. agent.spans.usage_id points at ai_usage_log one row at a time, so summing a tree through it sends every span's usage_id back as a filter. UsageMeter therefore stamps the root Run on each usage row, and the summary is one aggregate over that column. See observability and operations.

RunDetail.usage is the total of the whole tree, and a child reports the same total as its parent. The usage row carries agent_root_run_id, so the meter can answer a tree and it cannot answer one Run inside one. Detail therefore reads run_usage(root_run_id) for every Run. The alternative is a second stamped column on ai_usage_log, which V1 does not need: a person opens a child Run to read what it did, and the cost of the work belongs to the tree that asked for it.

Cancel is idempotent. Cancelling a finished Run is still a success. It answers 200 with the Run detail, so the caller reads the status it produced.

definition_name on a Run is the definition's current name, not the name frozen in the snapshot. A rename therefore moves every Run of that definition, which is what a person reading the list expects. The snapshot keeps the frozen name for an audit.

Every outcome has one status

RunManager.start() answers a closed set, and the route maps it. Nothing else on this route returns a status of its own.

StartRunResult.outcomeStatusBody
started200Run detail, outcome: started
duplicate200the Run the first call created, outcome: duplicate
definition_not_found404missing, or another organization's
definition_not_published409your own draft or disabled definition, with its state; or a skill, which never runs
snapshot_too_large409the definition is published and cannot be frozen; republish it
input_too_large413the body is over 32 KB; pass a ResourceRef instead
organization_budget_exhausted429Retry-After in seconds
parent_cancelledunreachablethe route refuses parent_run_id

404 and 409 are two answers on purpose. Another organization's definition must not be distinguishable from one that does not exist. Your own draft is the opposite case: the Builder shows it on the next tab, so 404 reads as data loss.

A start and a duplicate share one status, and the body separates them. Two concurrent POSTs on one key are a race: one inserts and one reads. If the two answered different codes, an identical pair of calls would return different codes on different days, and a client that branched on the code would be nondeterministic. So both answer 200 and carry outcome, which is the same closed value RunManager returned.

200 started does not mean the Run will execute. Policy admission runs after the Run row exists, so a started Run can be queued, waiting on an admission approval, or already failed on a policy denial. The client reads status, never the HTTP code.

On the generic definition-start endpoint, a repeated key with different content is still duplicate. The start key carries no request hash, because the Run row is the claim. The route namespaces the key per caller, so one person cannot read another person's Run out of a shared key, and a caller that reuses its own key gets its own first Run back.

Steering is deferred to V2, on the API and in chat. A person who wants to correct a live Run cancels it, then starts it again.

The Run tree

A Workflow creates a child Run for an agent step and for a subworkflow step.

  • The list returns root Runs by default. root_only=false returns the children too, and parent_run_id= returns the children of one Run.
  • root_only=true and parent_run_id= contradict each other, and the route answers 400 rather than choosing one. Silently dropping either filter returns a page that answers a question the caller did not ask. root_only is unset by default rather than true, so parent_run_id= alone reads the children: a caller that follows child_count from a detail does not have to unset a default it never set.
  • The spans route reads run_id, never root_run_id. Both are indexed, so the wrong one silently returns a whole tree instead of one Run. The UI loads a child tree when the user opens it.
  • Spans are paginated. The cursor is (started_at, span_id), because started_at is not unique and a bare timestamp cursor drops a row on a page boundary. The run list cursor is (created_at, id) for the same reason.
  • since= changes the order of the spans page to (updated_at, span_id), which is the index the reconnect reads. One route therefore holds two orders, so the cursor names the order it was written for and a mismatched pair answers 400. Untagged, a cursor from a since= page replayed without since= pages started_at from an updated_at value, reads the wrong rows, and reports no error.
  • A span payload is not on this route. agent.spans withholds input, output and error from authenticated, because they hold tool arguments and results. SpanNode.error therefore carries the error code and a redacted message, never the raw payload.

Live progress

One subscription is enough for a whole tree. Each event goes to the channel of the Run that produced it, and the publisher mirrors it to the root Run channel. A workflow therefore shows the work its children do.

⚠️ GET /runs/{id}/stream reads the channel of {id}, and it resolves no root. A reader that rewrote the id to the root would make every child channel unreachable, and the 20 child rule that moves a wide tree's detail onto the child channels would have no caller. Authority is still one 404 check on {id} before the subscribe.

The stream carries a hint and never a record, so three rules bound the connection. It writes retry: and never id:: Redis Pub/Sub keeps no backlog, so a Last-Event-ID a browser sent back would promise a replay that cannot happen. It closes on a terminal event for {id}, and a stream opened on a Run that already ended answers the terminal frame that Run would have published, off the row. An empty body is not a close: an EventSource re-opens on a clean close exactly as it does on a fault, so a stream that yielded nothing would be re-opened on the client's backoff for ever, at one authority read per attempt per viewer. The same frame answers a terminal event that was lost, which the status poll finds.

⚠️ A Run reaches a terminal status before its spans do, and the reader owns that gap. Three writers end a Run and close no span: RunManager.cancel, the retries exhausted handler, and the reaper before its own close_orphans. The reaper publishes after that close; the other two do not, so the sweep ends their spans up to a minute later. The reaper leaves a window of its own: _reap_page writes failed one Run at a time and closes that page's spans only after the last of them, so a Run failed early in a thousand-Run page reads terminal for tens of seconds before its spans close. The client closes its stream on the terminal event, so a client that refetches then reads those spans as running and nothing corrects it.

The stream does not close this, and the attempts to make it are recorded so they are not repeated. Withholding the frame keeps every viewer of a cancelled Run watching it read running for the whole reaper period. Gating only the frames the endpoint synthesizes leaves the live event ungated, which is the path a cancel takes. Gating a root on its whole tree blocks on the spans of children that are still alive, which nothing in a reaper pass closes, so the frame waits the full bound every time — and no partial index covers that read. A client therefore re-reads a Run it holds as terminal while any of its spans still reads running. The durable fix is a recorder that refuses to open a span under an ended Run, so the two settle together; that is not this phase.

Reconnect

Subscribe before you read. The reverse order drops every event that occurs between the read and the subscription, and V1 has no replay log.

TEXT
1. subscribe to SSE, and buffer what arrives
2. fetch the Run, then fetch the durable spans
3. apply the buffer, and drop anything older than the fetched state

Application is idempotent. The client upserts a span by span_id, and it keeps the newer updated_at. The durable record therefore always wins over a lost or duplicated event, so SpanNode returns the column.

A result item follows the same rule with different fields. The client upserts a prospect by item.id and keeps the newer item.updated_at. If equal revisions carry different bodies, or the stream reports stream.lagged, the client refetches the durable prospect state. The durable row decides the result.

The client also refetches the prospect list after the subscribed Run reaches a terminal status. This read covers a lost item event, a projection fault and child detail that the width gate did not mirror.

A live event needs no timestamp to lose that comparison. Span status is monotonic: every write to agent.spans is an insert, or an update filtered on status = 'running', so a span makes exactly one transition. A client that holds a span as ok or error therefore ignores any later event about it, and applies a span.completed only over a span it holds as running.

since= filters updated_at, and never started_at. A tool span that opened before the gap and closed inside it keeps its older started_at, so a filter on that column never reports the close and the client draws the span as running for the life of the page.

The filter is updated_at >= since. public.trigger_set_updated_at() writes now(), which is the transaction start time, so the orphan closer stamps every span of one UPDATE identically. A bare > on the newest value the client holds therefore drops each sibling written in that same transaction. >= re-answers the boundary on each poll, and the client upserts by span_id, so the repeat costs no correctness.

The client pages the cursor to exhaustion before it moves since. The boundary is not one row. close_orphans ends every running span of a batch in one statement, and a five hundred way parallel node is this page's own case, so a client that only re-sends the newest updated_at it holds re-reads that whole group forever and never reaches the span after it. A drain always ends, and two facts prove it. The cursor is (updated_at, span_id), which is unique and moves forward. And every update of a span filters on status = 'running' and writes a terminal status, so a row jumps the cursor at most one time. Cursor order alone would not be enough: a row that could move forward again and again would outrun any scan.

A wide reaped Run is briefly half closed, and no lock fixes it. RunSpanRepository.close_orphans pages past 200 ids, one transaction per page, so a since= between two pages reads some spans closed and some still running. That state is correct and momentary. The reaper publishes one run.failed after its last page, and the client refetches the spans of a Run on every terminal Run event, so the last read is the whole answer. It is one event and not two: RunManager.fail publishes nothing for the reaper, because a client closes its stream on the first terminal event it reads and an earlier one would land while those spans still read running.

The three admin surfaces

These three configure the platform. They are ordinary product CRUD, they start no Run, and V1 cannot ship without them: a scheduled Signals Search and an approval rule are both unreachable otherwise.

They are not conversational, so they speak the schema and call the domain API, exactly like the Approval Inbox and the Agent Builder. They add no service layer of their own.

SurfaceOwnsCalls
Trigger adminagent.triggers: pattern, filter, target, input builder, scopes, scheduleTriggerRepository
Policy adminagent.policies and agent.cost_ceilings rowsPolicyRepository, CostCeilingRepository
ConnectionsProvider credentials needed by the next product slice. Channel installs and MCP connections are deferredConnectionResolver
TEXT
GET    /api/v1/agentic/triggers?enabled=&cursor=
POST   /api/v1/agentic/triggers
PATCH  /api/v1/agentic/triggers/{id}                expected_updated_at
DELETE /api/v1/agentic/triggers/{id}
POST   /api/v1/agentic/triggers/{id}/enable
POST   /api/v1/agentic/triggers/{id}/disable

GET    /api/v1/agentic/policies?action=&cursor=
POST   /api/v1/agentic/policies
PATCH  /api/v1/agentic/policies/{id}                expected_updated_at
DELETE /api/v1/agentic/policies/{id}
GET    /api/v1/agentic/limits
PUT    /api/v1/agentic/limits

GET    /api/v1/agentic/connections?kind=&cursor=
GET    /api/v1/agentic/connections/{id}
POST   /api/v1/agentic/connections/{id}/reconnect
POST   /api/v1/agentic/connections/{id}/revoke

Rules

  • Organization admins only. These three rows change what runs without a person.
  • A trigger carries its own scopes. The save validates them against the authoring admin's rights at that moment, and PrincipalFactory meets them again with that admin's live rights at every mint. The save-time check stops an admin writing a scope they do not hold. The mint-time meet is what makes a trigger never outlive the authority that created it. Both run, and neither replaces the other.
  • A trigger row that authored no scope is refused at save. Admission already refuses it, and 02:00 is the wrong hour to learn that a row was never filled in.
  • A trigger has no service layer. The router calls TriggerRepository, exactly as the policy and saved-search routers call theirs.
  • Saving a policy validates every target fact it names against the action it binds to. An undeclared fact is refused at save time, not at run time. See policy and governance.
  • A connection never returns a credential. The row holds a vault reference, and the response holds the status only.
  • Deleting a trigger does not stop a Run it already started. Cancel the Run.
  • These routes start no Run, so they need no Idempotency-Key.

Core code

Each surface is a router over a domain component. A surface can add a bounded read model. The prospect capability also adds the shared product repository that its routes and later prospect tools use.

TEXT
src/agentic/surfaces/
  conversations/  router.py  schemas.py  stream.py    -> ConversationService, FrontDoorService
  approvals/      router.py  read_models.py           -> ApprovalService
  prospects/      router.py  read_models.py           -> services/prospects/ProspectRepository
  saved_searches/ router.py  schemas.py               -> SavedSearchService
  definitions/    router.py  schemas.py  tools.py     -> DefinitionService, ToolRegistry
  runs/           router.py  read_models.py  stream.py -> RunManager, RunSpanRepository
  triggers/       router.py  schemas.py               -> triggers/TriggerRepository
  policies/       router.py  schemas.py               -> PolicyRepository, CostCeilingRepository
  connections/    router.py  schemas.py               -> ConnectionResolver
  _shared/        org_scope.py  pagination.py  sse.py

src/agentic/services/
  prospects/      repository.py

src/agentic/entry_control/
  conversations/  models.py  repository.py  service.py  events.py
  channels/       adapters/web.py  models.py
  front_door/     service.py  protocol.py  agno.py  policy.py  capabilities.py
  inngest/        turn.py

CONVERSATION_EVENTS lives in entry_control/conversations/events.py, and it does not live beside RUN_EVENTS. It is one more ChannelScheme, on the prefix agentic:conversation, and it is a conversation concern. Put it beside RUN_EVENTS for the symmetry, and runtime holds a type that only entry_control and surfaces read.

The turn function lives in entry_control/inngest/turn.py, and inngest_functions/__init__.py imports it to register it. That is the shape run.execute already has: the function sits in runtime/inngest/execute.py, and the registry imports it. The registry is a list, and a list of functions has to reach the packages that hold them.

entry_control holds the conversational path. It sits above runtime, because it calls RunManager, and below surfaces, because the conversation router calls it. Front door § Core code components owns its import contracts.

surfaces is the top layer of the platform, and one more contract says so. runtime is already forbidden to services and governance. A new package that none of those contracts name may be imported by any of them, so runtime could read a read model and lint-imports would stay green. The contract therefore forbids services, governance, runtime, shared, capabilities, entry_control, inngest_app and inngest_functions to import surfaces.

⚠️ Every package under src.agentic is a source of that contract, and the list is written by hand. import-linter refuses a contract whose source and forbidden modules share a descendant, so src.agentic itself cannot be the source. A new top-level package joins the list on the day it lands, and entry_control is the next one.

⚠️ The direction is downwards, and a router reads every layer under it. surfaces/runs/dependencies.py reads build_platform() in runtime and the usage meter in governance. A contract that forbade those imports would refuse the code this page describes.

Every surface module sits under src.agentic, and that is the enforced path. The import-linter contract from coexistence binds src.agentic as its source module. A router under src/domains/ would be outside it, so it could import src.domains.workflows while lint-imports stayed green, which is the one thing the contract exists to stop.

org_scope.py holds the HTTP identity seam for agent schema resources. Their repositories write the organization filter because the service role bypasses RLS. A public product repository uses scoped_db(organization_id) instead. ProspectRepository is the first capability repository with that shape.

A read model exists so that a router never returns a domain entity.

PYTHON
@dataclass(frozen=True)
class RunSummary:
    id: UUID
    kind: RunKind
    definition_id: UUID
    definition_name: str
    status: RunStatus
    waiting_on: WaitingOn | None
    started_at: datetime
    ended_at: datetime | None
    root_run_id: UUID | None
    source: RunSource

@dataclass(frozen=True)
class RunDetail(RunSummary):
    input: dict                         # refused above 32 KB at start; never trimmed
    result: dict | None                 # already bounded at 32 KB by bound()
    error: ErrorSummary | None
    usage: UsageSummary | None          # null when the meter answered no row
    child_count: int                    # the children are a page of the list route
    pending_approval_id: UUID | None    # agent.runs.waiting_ref_id, on an approval wait

@dataclass(frozen=True)
class SpanNode:
    span_id: UUID
    parent_span_id: UUID | None
    kind: SpanKind
    name: str
    status: Literal['ok', 'error', 'running']
    started_at: datetime
    updated_at: datetime                # the reconnect keeps the newer of two copies
    duration_ms: int | None
    usage_id: UUID | None
    error: ErrorSummary | None

One helper answers visibility for every router. It is named for what it does, because a name like SurfaceAuthz invites the reader to mistake it for a second policy engine.

PYTHON
class SurfaceVisibility:
    """Visibility only. Policy decides every action below this."""

    async def run(self, principal: Principal, run_id: UUID) -> Run: ...
    async def approval(self, principal: Principal, approval_id: UUID) -> tuple[Approval, bool]:
        """Return the row, and whether this principal may resolve it."""
    async def definition(self, principal: Principal, definition_id: UUID, *, write: bool) -> Definition: ...

The stream services share one shape. They subscribe, and they publish nothing.

PYTHON
class RunStreamService:
    async def subscribe(self, run_id: UUID, organization_id: UUID) -> AsyncGenerator[str]:
        """Prove visibility of this run, then read its own channel."""

class ConversationStreamService:
    async def subscribe(
        self, conversation_id: UUID, actor: ActorIdentity
    ) -> AsyncGenerator[str]: ...

⚠️ A stream service yields SSE frames, and it yields no event object. The endpoint hands what this generator answers straight to StreamingResponse, so the frame is the unit. RunStreamService builds two frames off the run row that no publisher wrote, and a typed event in the middle would have to be built and then unbuilt.

⚠️ Neither takes a Principal. A principal is the frozen authority of one run, and a reader has none.

The two differ in what they take instead, and the reason is visibility. RunStreamService takes the tenant, because require_run filters on it alone. A conversation is read by the person who created it, so the tenant does not answer visibility on its own: ConversationStreamService takes the whole ActorIdentity and hands it to require_conversation, which owns both filters and the 404.

Data model

Two tables join the platform data model for web chat.

TableHolds
agent.conversationsOrganization, creator, title, summary, last activity
agent.conversation_messagesRole, sender, text, attachment count, originating run_id when one exists

agent.channel_sessions is deferred with remote interactive channels. When Slack lands, it maps an external thread onto a agent.conversations row without replacing the conversation. A shared Slack thread then holds one conversation and more than one sender. See the channel gateway.

Migration

Two start routes exist today, and agent access records them.

TEXT
POST /api/v1/agents/runs                  ->  POST /api/v1/agentic/runs
POST /api/v1/workflows/{id}/runs          ->  POST /api/v1/agentic/runs

The ac CLI drives both, so the change needs a CLI update in the same branch.

Cutover is Phase 9. Phase 7 delivers Company, People and Signals capabilities. Phase 8 delivers email sequences. The new runtime develops on the four repositories' agentic-platform branches. Production fallback remains separate until cutover. Targeted legacy chat removal already exists on trunk; do not infer trunk file presence from production coexistence. Phase 9 owns the remaining route migration and legacy removal. Phase 7 preserves the CRM and prospect routes named above.

One consequence binds the parity audit. audit_endpoints.py compares the CLI against a live /openapi.json, so a CLI that knows /api/v1/agentic/ reports every one of those paths as CLI-ONLY against a staging API that does not serve them yet. The CLI change therefore stays on the agentic-platform branch of ac-cli, and the audit runs against a branch API.

Rules

  • Web chat has no private path around Channel Gateway.
  • A schema-native surface does not go through Channel Gateway.
  • Admins author. Authorized users run.
  • A surface never duplicates definition validation, approval resolution or span logic.
  • A surface never writes Run state. It calls RunManager.
  • A surface returns no unbounded list. A related set is a page of a list route, never an array inside a detail response.
  • Every refusal a domain component returns maps to one status, in one table on the page that owns the route.
  • The durable record is authoritative. A live event is a hint.
  • Surface rendering may degrade. The decision and the state below it do not.

Open decisions

  1. Self-approval. V1 permits it, because a small organization has one admin. A later policy condition can require a second person.
  2. Redaction depth for proposed_content. The Tool result rules apply today. Confirm they are enough for an email body.

Minimum contract tests

  • A web chat POST returns 202 before any model call.
  • The message row is committed before the 202, so an immediate GET /messages reads it.
  • A repeated Idempotency-Key answers 200 with the first message, and one turn runs.
  • A message over the text limit answers 400, and writes no row.
  • A Front Door answer arrives on the conversation stream, and it creates no Run.
  • A conversation in another organization and a missing conversation return the same 404.
  • A colleague's conversation and a missing conversation return the same 404.
  • An empty message and a whitespace-only message both answer 400, and write no row.
  • An impersonated session reads a conversation, and a write answers 403.
  • One Idempotency-Key reused in two conversations writes two messages.
  • A duplicate POST sends the durable event again, and one turn runs.
  • The message list and the conversation list both read newest first.
  • Two messages posted 200 ms apart in one conversation produce two turns that do not interleave, and they run in order when neither retried.
  • A turn over the organization day ceiling writes an assistant message and makes no model call.
  • A retried turn writes one assistant message, and starts at most one Run.
  • A retried turn makes one model call, and writes one usage row.
  • A failed turn writes one system message, and a second retry writes no copy of it.
  • A delegation writes one assistant message carrying run_id, then run.started.
  • A policy denial carries message.completed and no run.started.
  • Every frame of one turn carries the same message_id.
  • A turn publishes turn.failed only after Inngest spends every attempt.
  • Two messages in different conversations run at the same time.
  • The conversation stream carries no terminal event, and the client closes it itself.
  • The client subscribes before it sends, and it reconciles after each resubscribe.
  • A message.completed lost to a gap is read back by the reconcile, and the message stops pending.
  • A page read merges into the held messages, so a frame that arrived first survives it.
  • turn.failed and stream.lagged both refetch, and neither renders text of its own.
  • A reconnect that subscribes before it reads loses no span.
  • A workflow parent stream carries the span events of its children.
  • A child Run stream carries that child's events, and the reader resolves no root.
  • since= answers a span that closed inside the gap.
  • since= set to the exact updated_at of a closed span still answers it.
  • A spans cursor written under since= and replayed without it answers 400.
  • A stream opened on a Run that already ended answers a terminal frame, and the client closes on it.
  • A stream whose Run row vanishes still answers a terminal frame.
  • The runs list excludes child Runs by default.
  • A run detail response carries no child array, whatever the width of the tree.
  • The runs list and the spans list both page correctly when two rows share a timestamp.
  • POST /runs refuses a body carrying parent_run_id.
  • A start against another organization's definition and a start against a missing one return the same 404.
  • A prospect in another organization and a missing prospect return the same 404 on detail, child pages and curation.
  • Empty prospect, people and signals pages return an empty items array and no next cursor.
  • The three prospect pages do not drop or repeat rows when two rows share one created_at.
  • A score update between two prospect pages does not move that prospect across the cursor.
  • Prospect pages preserve null opportunity scores and null signal scores.
  • Prospect detail, people and signals responses preserve every documented nullable field without inventing a value.
  • A repeated watch or dismiss writes nothing and returns 200.
  • Concurrent watch and dismiss requests both succeed, and a refetch returns the last accepted database state.
  • A watch or dismiss racing with promotion never overwrites promoted.
  • A promotion racing with a second promotion writes one company, one row for each person, and one promoted.
  • Prospect responses carry no related array, source_ids array or raw provider payload.
  • A saved search create and read preserve every unknown brief field and return normalized supported fields.
  • A saved search list returns no brief.
  • A brief with no ICP and no company criteria is refused.
  • A brief with no usable persona is refused on create, patch and start.
  • A legacy brief with no persona remains readable and deletable. A name-only patch can rename it; a brief patch must repair the persona.
  • A brief whose composed Run input exceeds 32 KiB is refused before storage.
  • Missing and foreign saved searches return the same 404 on every route.
  • Two saved searches with the same trimmed name in one organization return 409.
  • An empty patch writes nothing, and a stale update token returns 409.
  • The saved-search list pages correctly when two rows share one created_at.
  • Starting a saved search freezes its brief and calls the normal Run lifecycle.
  • Starting a saved search freezes the current published Run as its comparison baseline.
  • Two saved-search starts with one idempotency key create one Run.
  • Concurrent same-key starts cannot freeze different briefs or baselines; a preparation loser retries the same key.
  • Starting a saved search creates no Trigger or schedule.
  • A saved search with no published Run returns an empty diff and a null Run id.
  • A published Run with no material change returns an empty diff and its Run id.
  • A failed or cancelled Run does not replace the latest Smart Feed pointer.
  • A cancellation after workflow execution but before success or dispatch does not publish.
  • A missing, dropped or schema-invalid Smart Feed projection does not publish.
  • A partial Run does not publish. Ref-only truncation does not block an intact projection.
  • A result without a ready final observation node does not publish.
  • A final observation with 100 signals or people for one prospect, or an oversized answer, returns a non-ready result and publishes nothing; it never prunes prospects or components.
  • Of two overlapping successful Runs with one baseline, only the first publication compare-and-set replaces the pointer.
  • A successful Run whose baseline is stale remains in Run Explorer and does not replay an earlier diff.
  • The final observation node and another Run can race without mixing their prospect, signal or people state.
  • A prospect last written by another Run before the final snapshot makes the older result superseded.
  • A durable publish dispatch retries, and the next Run keeps the baseline when dispatch never completes.
  • A partial failed write cannot alter a prior successful Run's diff.
  • Deleting the saved search before the immutable insert, pointer move or membership write makes that boundary a clean no-op.
  • A publish event with a forged or mismatched organization and Run id writes nothing.
  • Every observed prospect has one immutable Run row; an unchanged row has no change reason.
  • A retry cannot overwrite an immutable observation with different values.
  • A retry after pointer movement finishes an interrupted membership update.
  • A later Run repairs missing baseline membership after terminal consumer failure.
  • Each change kind follows the versioned digest rule, and an unknown version publishes nothing.
  • A diff returns only immutable Run rows whose run_id matches the latest Run and whose change reasons are not empty.
  • A diff response carries no digest and does not recompute change reasons.
  • A newly published Run between diff pages returns 409; it never combines two Runs.
  • No saved-search route accepts a historical Run id.
  • A start against the caller's own draft or disabled definition returns 409, and names the state.
  • A POST /runs with no Idempotency-Key, or one over 255 characters, returns 400.
  • Two people in one organization sending the same Idempotency-Key create two Runs.
  • A stale expected_updated_at fails cleanly, on a draft save and on a publish.
  • Two POSTs to /runs with one Idempotency-Key create one Run.
  • Two concurrent POSTs with one Idempotency-Key both answer 200, both name the same Run, and one carries outcome: started and one outcome: duplicate.
  • Cancel is idempotent on a running, a waiting and a finished Run.
  • A disabled definition blocks a new Run, and it does not stop an in-flight Run.
  • The Builder cannot publish an invalid Agent, Workflow or Skill.
  • The Builder cannot disable an active definition that another active definition references.
  • A platform template cannot be edited, and a fork of it can.
  • A platform template appears in the definitions list, and its detail route answers 404.
  • A disabled platform template appears in no list, and no fork.
  • The definitions list pages correctly when two rows share a created_at.
  • An empty PATCH .../draft body answers 400, and the row's updated_at does not move.
  • A POST .../definitions by a non-admin answers 403, and writes no row.
  • Two concurrent approval decisions produce one state transition.
  • An expired approval and a stale-hash approval do not execute.
  • A principal from another organization receives 404 on every route.
  • A non-admin cannot write a trigger, a policy, a limit or a connection.
  • A trigger cannot be saved with a scope its author does not hold.
  • A policy naming an undeclared target fact is refused when it is saved.
  • A connection response never carries a credential.
  • Run Explorer rebuilds its state from the Run and the spans, with no event log.