Tools and integrations
A tool is the one way an agent reaches the world. AgencyCore owns the model facing contract, the invoke path, the credentials and the result boundary.
Tools and integrations
A tool is one atomic capability an agent may call. It is the only way an agent changes anything.
The model proposes. ToolInvoker decides, executes and bounds the answer.
Agno agent
-> AgnoToolAdapter declaration
-> proposed tool call
-> ToolInvoker
-> live tool state
-> input schema
-> journal read (write and send)
-> policy action checkpoint (stage 1 grant, stage 2 rules)
-> accrual checkpoint (metered)
-> connection resolve (a tool that declares a connection)
-> count budget reservation (one slot of each class the tool declares)
-> idempotency claim (write and send)
-> handler
-> result boundary
-> span + usage
-> ToolResult
-> Agno agent continues
Tool, skill, service
| Concept | Meaning |
|---|---|
| Tool | One callable business action, such as email.send |
| Skill | Reusable guidance for how an agent should use the tools it has |
| Service | The implementation behind a tool |
A skill teaches. A tool acts. Never expose a raw REST route, a generic api_request, a repository or SQL to a model.
Four checks
| Check | Owner | Question |
|---|---|---|
| Visibility | ToolRegistry | Which tools does this agent see? |
| Action decision | Policy | May this call happen now, with these arguments? |
| Business validation | Tool handler | Are the arguments and the business state valid? |
| Tenancy | The store's explicit tenant guard | Which rows belong to this organization? |
Do not collapse the four. Visibility is not authorization.
For public service-key reads, scoped_db(organization_id) is the tenant guard. Agent-schema reads filter organization_id explicitly. RLS remains a guard for user-key paths, but it does not filter the API service key.
Core code
services/tools/
models.py ToolSpec, ToolResult, ToolError,
NAME_PATTERN, model_tool_name
registry.py ToolRegistry, build_registry
declarations.py code declared specs + MCP discovered specs
invoker.py ToolInvocation, ToolInvoker, ApprovalRequired
default_invoker.py policy, approval, accrual, connection, count budget,
idempotency and execution order
output_validation.py output normalization and JSON Schema validation
result_boundary.py redaction, bounding, untrusted content envelope
connections/
models.py Connection, ResolvedConnection, the stable codes,
the provider rollout gates
repository.py every statement on agent.connections
resolver.py ConnectionResolver: the four cases, the three states
link.py ProviderLinkFlow protocol for a hosted sign-in
links.py agent.connection_links: the one-time sign-in links
hosted_sign_in.py the binding rule of a hosted sign-in, and the state
unipile_link.py UnipileLinkFlow, the LinkedIn hosted sign-in, the base URL check
handlers/
base.py ToolHandler protocol
crm/
common.py shared CRM handler helpers
companies.py company reads, updates and canonical upsert
people.py people search and canonical upsert
lists.py idempotent list membership
memory.py durable observation and preference memory
research.py Exa, Parallel, Firecrawl
linkedin/
base.py the provisional Unipile v2 error map, the free-account rule
reads.py linkedin.read_profile, invite_status, read_inbox
sends.py linkedin.send_invite, send_message
visits.py linkedin.visit_profile
targets.py the LinkedIn person URL and its public identifier
services/linkedin/
store.py every statement on the three LinkedIn record tables
webhooks.py UnipileWebhookReceiver: the v2 Event envelope, the inbox scope, store each delivery, apply it, raise
webhook_events.py agent.linkedin_webhook_events
people.py the CRM person read of the inbox scope
surfaces/webhooks/
linkedin_events.py the sink that sends a receiver event through the event router
surfaces/connections/
access_log.py the access log filter that removes the state of a sign-in callback
The Unipile v2 client is src/clients/unipile_client.py, outside src/agentic. The hosted sign-in callback is surfaces/connections/callback.py.
The count budget checkpoint and its ledger live in the policy plane, at
governance/policy/action_budgets.py, because policy owns the values and the
ledger owns the count. runtime/inngest/wiring.py builds the connection
resolver and the count budget checker and passes both to DefaultToolInvoker.
The nine LinkedIn tools declare a connection, and seven of them declare a budget
class. See LinkedIn through Unipile. A deploy
registers the LinkedIn link flow only when the rollout gate is on and the
Unipile key and the API base URL are both set.
The Nylas email handler and the generic MCP handler are planned extensions.
They are not modules in agentic-platform yet.
AgnoToolAdapter is not in this package. It lives beside the runtime it
serves, at runtime/agent/agno/adapter.py, because it declares an Agno callable
and raises an Agno exception. Keeping it here would put Agno imports in two
packages, and the import contract could then name neither of them. Nothing else
in this page moves: the registry, the invoker, the policy and the handlers stay
framework neutral. See the Agno boundary.
ToolSpec
ToolSpec is the framework neutral contract. AgnoToolAdapter shows the model
the description and the input_schema, under a derived name.
class ToolSpec(BaseModel):
name: str # the platform name: domain.verb, globally unique and stable
description: str # the model reads this
input_schema: dict # JSON Schema
output_schema: dict # JSON Schema
version: int = 1 # bumped whenever a schema changes
schema_hash: str # of input_schema + output_schema; the resync compares it
kind: Literal['internal', 'vendor', 'mcp']
binding: HandlerBinding | RemoteBinding
side_effects: Literal['read', 'write', 'send']
connection: str | None = None # provider key the ConnectionResolver must satisfy
budget_class: str | None = None # the count budget one call spends, such as 'linkedin.messages'
secondary_budget_classes: tuple[str, ...] = () # the other count budgets one call spends
send_window: bool = False # the invoker checks the connection's send window before the budget
timeout_s: int = 30
retry: Literal['none', 'safe'] = 'none'
metered: bool = False # the handler writes a usage row
repeatable: bool | None = None # required and false for write/send; absent for read
approval_preview: bool = False # the handler renders the approval summary
policy_facts: list[str] = [] # target facts a rule may read about this call
batch_argument: str | None = None
max_batch_size: int | None = None
max_results: int | None = None
max_output_bytes: int | None = None
item_kind: str | None = None # the ResourceRef kind of one item of `data`
Eight fields deserve an explanation.
nameis the platform name, and the model never sees it. OpenAI and Anthropic both accept a letter, a digit, an underscore and a hyphen alone in a function name, so either provider refusesemail.sendon the first model call. OpenAI documents a cap of 64 characters, and the platform takes the lowest cap a provider documents.AgnoToolAdapterderives the model facing name when it declares the tool, and the adapter owns that map alone. A read call carries the platform name in its own body. A paused write or send reaches the drain loop under the model facing name, and the adapter maps that one back. Every one of these keeps the dot:Principal.scopes,declared_scopes, every published definition, every frozen snapshot, every policy rule, every idempotency scope and every live run event.input_schemais JSON Schema, not a Python type. An internal or vendor tool declares a Pydantic model in code, and registration generates the schema from it. An MCP tool carries the schema its remote server published. One field then serves both, and the catalogue row stays storable.bindingnever carries code, and it is not a string.PYTHONclass HandlerBinding(BaseModel): handler_key: str # resolves in the trusted handler registry class RemoteBinding(BaseModel): connection_id: UUID remote_name: str # the tool name the remote server published
A single'<connection_id>:<remote_name>'string needs a parser, and a parser gets written twice and disagrees with itself once. A database row still cannot introduce executable Python:handler_keyresolves in a registry the deploy owns.budget_classnames a count budget, and any side-effect class may carry it. A profile read spends a vendor quota as a message does, so areadtool may declare one. The class needsconnection, and the part before the dot must equal that provider. One call spends one unit of the class on the connection the resolver chose. See count budgets.secondary_budget_classesnames the other counts one call spends. A LinkedIn send reads the profile first, and LinkedIn counts that view. Solinkedin.send_invitedeclareslinkedin.invitationsas itsbudget_classandlinkedin.profile_readsas a secondary class. A secondary class needs abudget_class, and each class follows thebudget_classrules. The main class is the one thatmeta.no_effectreleases.retryissafeonly for a read. A write or a send retries through Inngest and the idempotency claim, never inside the handler.policy_factsis how a rule asks a question the arguments cannot answer. A rule such asemail.send AND recipient_is_newneeds a CRM lookup. Policy may not query domain data, and the handler runs after the decision, so the tool declares the fact and the invoker resolves it first. See policy and governance.batch_argumentnames the public list thatmax_batch_sizebounds. The two fields appear together or not at all. The name must resolve to a top-level array ininput_schema. An optional batch that is absent counts as zero items. The invoker does not guess from every list in the argument tree. A filter list is not necessarily a batch, and two lists can form a Cartesian product.item_kindis what lets the invoker turn aDroppedmarker into aResourceRef.bound()writes a marker and never a ref, because a pure function has no row to name. The invoker does hold the rows, and it is the one caller that substitutes — but a marker carries a size and a reason, not a kind. So the tool declares the kind of one item of its owndata, such ascrm.person, and the invoker reads theidoff the item it replaced. A tool whosedatais not a collection of rows leaves the field unset, and its markers stay markers.
There is no scopes field. V1 has one scope vocabulary: the platform name. Principal.scopes holds the tool names the run may call, and it is already the intersection of the agent grant and the caller rights. A second vocabulary adds a mapping table and answers no question the tool name does not answer.
V1 needs no tool revision history. A run freezes the contract it showed the model, and reads the live enabled state, the policy, the credentials and the handler at each call.
Where a spec comes from
Two sources, one catalogue.
| Source | Declared | Refreshed |
|---|---|---|
| Internal and vendor | In code, next to the handler | On deploy |
| MCP | Listed from the connected server | On connect, and on a scheduled resync |
internal_tool() and vendor_tool() generate schemas from the same Pydantic models and create the same HandlerBinding. The internal factory sets kind='internal'. The vendor factory sets kind='vendor'. The vendor factory does not infer a connection, metering, retries, or limits. A caller declares each supported field explicitly. The MCP resync builds its own remote declaration when that binding lands.
An MCP tool is named mcp.<connection_slug>.<remote_name>. The prefix keeps a remote server from claiming a platform name. Registration rejects a platform name collision instead of shadowing.
An MCP server may drop or change a tool between runs. The invoker therefore checks that the bound remote tool still exists before it executes, and returns tool_unavailable when it does not.
Schema drift is a version check, not a presence check
Presence is not enough. A server that keeps a tool and changes its input schema accepts the call and rejects the arguments, so a run fails with invalid_input on a call that was valid when the snapshot froze. The model is shown the frozen contract and cannot know.
Every spec carries a schema_hash and a version, and the resync compares the first to decide whether to bump the second. Both are fields on ToolSpec above, because the check reads them from a frozen snapshot and from the live registry, and a value that lives in neither cannot be compared.
on connect and on resync
hash the remote input_schema and output_schema
unchanged -> nothing happens
changed -> write the new spec, and bump ToolSpec.version
at invoke time
snapshot.version == registry.version -> execute
differ -> ToolResult error 'tool_changed'
tool_changed is a stable error code and a value, not a raise, so the agent reads it and adapts exactly as it does for denied. A workflow tool node fails its node, and continue_on_error decides the rest.
The resync runs hourly per connection, and on every connect. An hour is the exposure window, and tool_changed closes it at the moment of the call rather than at the moment of the sync.
This answers what was open decision 1: a changed schema neither disables the tool nor forks it. The new spec wins for a new run, and a run holding the old contract stops cleanly instead of sending wrong arguments.
ToolRegistry
The registry answers what a tool is.
class ToolRegistry:
async def get(self, name: str) -> ToolSpec: ...
async def resolve(self, names: list[str]) -> list[ToolSpec]: ...
async def list_for_organization(self, organization_id: UUID) -> list[ToolSpec]: ...
The registry answers catalogue resolution and visibility. It never answers per call permission.
The full catalogue never reaches a model. An agent sees the tools its definition names, and nothing else.
There is no ToolFactory, and there was one
The factory closed each spec over ToolInvoker and answered a BoundTool list, and
AgentExecutor called it once per segment. Nothing calls it now, because two later
changes took both of its jobs.
The snapshot took the visibility half. A definition names its tools, publication
freezes those contracts, and the run reads them as AgentExecutionRequest.tools. A model
therefore sees exactly the frozen list, and a factory filtering the same list a second
time answers the same list.
The adapter took the calling half. AgnoToolAdapter turns one
frozen contract into one framework callable, and ToolInvocation carries the ambient
identity a closure used to hold. A BoundTool between them is a third shape for one fact.
So visibility is the snapshot, and authority is stage 1 of the policy engine. Two mechanisms, in two layers, and neither restates the other.
⚠️ ToolInvoker holds no scope check of its own. Stage 1 reads the grant, live, at
the action checkpoint. A frozen read in the invoker would pass a tool the live read
stripped, and it would refuse before the engine ran, so a scope refusal would reach no
agent.policy_decisions row.
⚠️ principal.scopes must therefore hold the definition's tool names. The principal
is the intersection of the agent grant and the caller rights, and stage 1 refuses a call
whose name it does not find. A principal minted with an empty scope list refuses
every call a run makes, and the refusal reads as a policy decision rather than as an
unfilled field. The same set is what a child run intersects against, so an empty parent
grant empties every grant below it too. See runtime execution.
ToolInvocation and handlers
@dataclass(frozen=True)
class ToolInvocation:
"""The ambient identity of one call. It is not model context."""
organization_id: UUID
user_id: UUID | None
run_id: UUID
definition_id: UUID
step_path: str # workflow node ID, or the literal 'agent'
principal: Principal
ceilings: RunCeilings # the REMAINDER for this segment, not the frozen budget
deadline: datetime
source: RunSourceKind
class ToolHandler(Protocol):
async def execute(self, inv: ToolInvocation, args: BaseModel) -> ToolResult: ...
class ApprovalPreviewHandler(Protocol):
async def preview(self, inv: ToolInvocation, args: BaseModel) -> str: ...
The model supplies the public arguments only. The organization, user, run, definition, principal, source and every credential are runtime owned.
There is no span_id field, and there was one. One invocation is built once per segment and shared by every call in it, so an invocation is older than every call made through it: one span_id would name one span for thirty tool calls. Span identity is ambient for exactly this reason, read through current_span_id(), and ToolInvoker opens the tool span itself as a child of whatever is current. Two mechanisms for one fact disagree the first time either moves, and this pair disagreed on its first call. See observability.
ceilings carries the remainder for four bounds, and the frozen budget for the fifth. AgentExecutor subtracts the turns, the tool calls and the wall clock a run already spent, so a metered call reads what is left of each. It subtracts nothing from max_cost_cents, because the meter answers the spend of the whole tree and a total compares against a total. Subtract there as well and the run stops at half its budget.
preview() is optional. A tool that sets approval_preview renders the line a person reads in the approval inbox, because only the handler knows that email.send means "one message to 3 new recipients". Without it the inbox falls back to a generic argument render.
preview() must be read only, and it runs on a call nobody has allowed yet. Policy has just answered require_approval, so the action is explicitly not authorized, and the invoker calls the handler anyway to render the line. Five rules keep that safe.
- It performs no write, no send, and no metered call. A preview that spent money would spend it on a request a person then rejects.
- It reads under the same principal, so it can see nothing the run could not.
- It runs under the shorter of
timeout_sand the run deadline. A slow summary cannot hold the approval path without a bound. - It returns a string. The invoker trims it and replaces each whitespace run with one space, so the stored value is one non-empty line.
- The normalized line is at most 512 UTF-8 bytes. A non-string, an empty line, an oversized line, a timeout, or an exception stores
preview=None. Human Review then keeps its argument view. The invoker logs the fallback, and the approval still exists.
| Handler family | Reaches | Credential |
|---|---|---|
| Internal, organization data | The AgencyCore application boundary | Run scoped principal, and scoped_db(organization_id) |
| Internal, global Intelligence | Dedicated Intelligence domain stores | Service-role client used only by those stores |
| Vendor | Nylas, Exa, Parallel, Firecrawl | Platform or organization credential from the vault |
| Vendor, account connection | A vendor that acts as one external account | The platform key from the deploy, and the account id from the connection row. See connections |
| MCP | An approved remote MCP server | Organization connection and vault credential |
Vendor and MCP output is untrusted input.
An internal handler is not protected by RLS, and calling it that is the dangerous shorthand. ac-python-api reads public on the service key, which bypasses every policy, so the tenancy guard is scoped_db(organization_id) applying an explicit filter against ORG_SCOPED_TABLES. A handler that reaches for the admin client directly compiles, passes review and reads every tenant.
Global Intelligence is the narrow exception. public.intel_* has no organization_id, is absent from ORG_SCOPED_TABLES, and denies every user role through RLS. Its dedicated domain stores own the service-role client. An Intelligence handler calls those stores and never imports the admin domain or the client. A test must prove that the write does not include organization_id.
⚠️ The guard is scoped_db, not "go through the domain service". Both CRM services reach the legacy agent stack:
crm.companies.service -> crm.activities.service
-> domains.envoy.sequences.steps.step_orchestrator
-> domains.envoy.sequences.service
-> workflow_engine.services.workflow_execution_service
src.agentic never imports the legacy agent stack is an import-linter contract. The legacy stack stays a working fallback until cutover. Importing either service breaks the contract and pulls agno into a second package. crm.people.service reaches the same edge.
So an internal handler takes scoped_db directly, exactly as
crm.search.service already does, and never the admin client. It does not
import a legacy domain service or repository.
A platform-local capability service under src.agentic can share write rules
with a platform surface. The service takes the same scoped_db client and
does not cross into the legacy stack. Do not add this layer when the handler is
the only caller.
The handler or its platform-local service carries what the legacy service was carrying: the soft delete filter and the column projection. A tool must not answer a row the product hides. Both rules belong in a test, because neither rule is visible in a review of the query alone.
ToolInvoker
class ToolInvoker:
async def invoke(self, tool_name: str, args: dict, inv: ToolInvocation) -> ToolResult: ...
The path is fixed. The capabilities diagram at the top of this page does not show the connection resolve or the count budget reservation. This path is the authority for the order.
open the `tool` span, as a child of current_span_id()
-> resolve the snapshotted contract
-> check the live enabled state, the remote tool presence, and the spec version
-> validate the input schema and max_batch_size
-> read the journal when write or send
absent -> continue
completed -> return the stored result, and tag the span replayed
processing -> raise ClaimInFlight; another worker holds a live lease
conflict -> ToolResult error 'conflict'
-> read the authorized_by the runtime handed this segment
covers the call, approved -> the action checkpoint is already decided
covers the call, rejected -> ToolResult error 'rejected'
names another proposal -> ignored; the call is decided live
-> resolve the policy_facts a matching rule needs
-> policy action checkpoint
stage 1 reads the grant, live, so a right stripped mid run is gone here
fault -> ToolResult error '<fault_code>', stop 'fault'
deny -> ToolResult error 'denied'
require_approval -> the claim already holds a rejection of this call
-> ToolResult error 'rejected'
otherwise
-> persist the approval, raise ApprovalRequired
allow -> continue
-> accrual checkpoint, when the tool is metered
deny -> ToolResult error 'budget_exhausted', stop 'budget_exhausted',
meta.partial_reason 'budget_exhausted'
fault -> ToolResult error <the fault code>, stop 'fault'
-> resolve the connection, when the tool declares one
resolved -> continue, with the connection id
refused -> ToolResult error 'connection_required', 'connection_ambiguous',
'connection_expired', 'connection_revoked' or
'connection_unavailable'
-> check the send window, when the tool declares send_window
inside -> continue
outside -> ToolResult error 'outside_send_window'; no slot is taken
-> reserve one count budget slot of each declared class, all or none
reserved -> continue, holding one ledger row of each class
cap reached, or a day hold on the connection
-> release the rows already held,
ToolResult error 'budget_exhausted',
meta.partial_reason 'action_budget_exhausted', stop None
read or write failed
-> release the rows already held,
ToolResult error 'budget_unavailable', stop 'fault'
-> claim the idempotency key when write or send
claimed -> continue
completed -> release the ledger rows, return the stored result,
and tag the span replayed
processing -> release the ledger rows, raise ClaimInFlight;
another worker holds a live lease
conflict -> release the ledger rows, ToolResult error 'conflict'
-> resolve the handler
-> execute under timeout_s and the run deadline
-> validate the output schema
-> redact, bound and tag the result
-> complete the claim, or release it when the failure is retryable
-> settle each ledger row: spent, released, or left reserved
-> close the span, link the usage row
-> ToolResult
Only ToolInvoker calls a handler from an execution path. Nothing else does.
The draft turn of a form chat is the one documented exception, and it calls no handler either. It runs in no run scope, so its four read tools call the vendor clients and the CRM helpers that the handlers call, and the turn meters them itself. Front Door § A draft turn researches states them.
The connection resolves after accrual, and before the count budget and the claim. The count budget is keyed on the connection, so the reservation cannot run until the resolver names one. A connection refusal is a refusal before any effect. It takes no reservation and no claim, so it leaves nothing to release. The reservation runs before the claim for the same reason: a spent budget refuses the call, and a claim taken first would then have to be released.
The send window sits between the connection and the count budget. The window belongs
to the connection, so the check needs the resolver first. It runs before the reservation,
so a call outside the window takes no slot. It also runs after the policy checkpoint, so
an approved send meets the window when it resumes. A send that a person approves at
21:00 answers outside_send_window. See
the send window.
The span opens first, so a refused call is still in the tree. denied, rejected,
tool_changed, invalid_input, conflict, budget_exhausted, budget_unavailable,
outside_send_window and the five connection codes all return before the handler runs,
and each one is a thing the model tried to do. A span opened after the checkpoints would record the calls
that succeeded and hide the calls that policy stopped, which is the opposite of what an
auditor opens the tree for. The refusal closes the span with status = 'error' and its
stable code.
The journal is read before the decision, and claimed after it
The journal read and the claim are two steps, and the decision sits between them. A design that read the journal inside the claim would put the whole answer after the policy checkpoint. A turn that holds two gated calls would then never finish.
requirement 1 email.send(to='jo@acme.test') gated
requirement 2 email.send(to='sam@other.test') gated
pass 1 call 1 raises. Nothing ran
resume A call 1 is approved and runs. Call 2 raises
resume B approval B names call 2, so call 1 reaches the invoker unauthorized
At resume B the journal answers completed for call 1. The pass moves on and decides
call 2.
Read the journal after the checkpoint instead, and policy answers require_approval for
call 1 a second time. The inbox then shows a person a message the platform already sent.
Every resume repeats that, and the run ends on the tool call ceiling.
A decision about an effect that already happened is not a decision. Policy cannot unsend the message. The platform already recorded the answer, so the journal answers first.
The claim itself stays after the checkpoints. A call that stops for a person takes no claim, and the claim it would have taken is the one the resume needs. A connection refusal and a count refusal take no claim either.
⚠️ The read can go stale, so the claim decides again. The checkpoints take time, and a
human round trip takes days. Another attempt of the same segment can complete the very key
this one read as absent. The claim is therefore the authority.
The two steps answer four outcomes each, and the four are not the same four. Only
read() answers absent, and only claim() answers claimed. One shared branch helper
gives the claim path an absent -> continue arm. A claim that raced and lost then reaches
the handler, and that is the second message this step exists to stop.
read() absent completed processing conflict
claim() claimed completed processing conflict
The key is derived once, before the read. Both steps use that one value. V1 refuses
repeatable = True, so no ordinal can make the read and claim name different keys.
⚠️ A replay is answered before the checkpoint, and policy is not asked. The effect already happened, so policy is never asked to decide it again. Only a write or a send journals, and the key names one organization, one run, one step and one arguments hash, so a replay is always one run reading back its own effect. The first call this run has not already made meets the live grant and the current rules.
A metered call checks the budget
AgentExecutor checks accrual before each segment, and WorkflowStepExecutor before each node. That is enough while a node costs about the same as a model turn. It is not enough for a fan out.
Signals Search searches people across every company that survived pruning, inside one step. Between the accrual check at the top of that node and the check at the top of the next one, the run can make hundreds of paid vendor calls. The ceiling is read once, and the overshoot is the whole fan out.
So a tool that declares metered passes the accrual checkpoint before it executes.
ToolSpec.metered = True -> the invoker calls accrual first
ToolSpec.metered = False -> no extra read; the node boundary is enough
This adds no fourth checkpoint. Accrual keeps one definition and gains a second caller, exactly as the action checkpoint has one definition and many callers.
A metered call reads the run tree only. The organization day ceiling is checked at the segment and node boundaries, and not on every paid call. A run cannot pass the organization ceiling faster than it passes its own, so the second read buys nothing and costs a whole-day aggregate per call inside a fan out.
before a metered tool call the run tree total indexed on root_run_id, one run's rows
before a segment or a node the run tree AND the day rare, so the cost does not matter
Both read ai_usage_log, the canonical meter. Neither reads ai_usage_daily: a rollup lags, so a run's own in flight spend is missing from it and the ceiling would never fire. See policy and governance.
A run stopped this way still succeeds with a partial reason. The invoker returns budget_exhausted as a value, so the agent or the step can finish with the items it already has. See policy and governance.
A metered call the meter cannot see
Two things stop the checkpoint before it reads anything. No accrual checker is wired, or the call runs outside a run scope. Both are wiring faults, and both answer internal_error.
They never answer budget_exhausted. The money code sends an operator to ai_usage_log and to the run ceilings, and both show headroom.
Sentry receives the fault one time. The log receives every call. The fault runs on the path of one tool call. A fan out node makes hundreds of calls inside one step. A report per call gives the Sentry issue one repeated message. That message then hides every other defect. The invoker holds no durable step, so it holds a set of the faults it reported.
WorkflowStepExecutor bounds the same shape by a different amount. Its durable step reports one time for each step execution, so a later node and a later run report again. The invoker's set reports one time for the life of the invoker.
The key names the tool and the cause. It holds no run id. Both causes are static. A missing checker is a defect of the deploy. A missing run scope is a defect of the call site. Neither one varies by run, so a run id in the key gives one report for each run of a fan out.
⚠️ An operator who resolves the Sentry issue receives no second event. That invoker stays silent for its life. Both causes change only on a deploy, and a deploy builds a new invoker with an empty set. The log carries every call until then, and a retry in another worker process gives one more report.
A budgeted call reserves a count
Accrual counts money. Some vendors restrict a count instead, such as a number of
invitations each day on one account, whatever the calls cost. A tool that declares
budget_class therefore reserves one unit of that class on the resolved connection
before its claim.
ToolSpec.budget_class = 'linkedin.messages' -> reserve one row in agent.action_ledger
ToolSpec.budget_class = None -> no read, and no row
A call holds one row of each class it declares, or none. The invoker
reserves the budget_class first, then each secondary class, under one call key.
A refusal on one class releases the rows the call already holds, and the
refusal names that class. A call that does not run therefore holds no row.
The caps, the windows and the slot design belong to policy and governance. This section holds what the invoker does with the row.
The refusal is a value, and the run continues. A spent budget answers
budget_exhausted with meta.partial_reason = 'action_budget_exhausted' and
stop = None. The agent reads it and does other work, or finishes. The budget returns
in the next window, so a stop would discard the work of the run and gain nothing. The
code is the same code the money refusal uses. stop separates the two, and every caller
reads stop, so a count refusal returns to the model as denied does.
A reservation that cannot complete denies. A ledger read or write that fails answers
budget_unavailable with stop = 'fault', and so does a reservation that loses every
attempt. A count the platform cannot read cannot govern a vendor account. The run ends
failed under that code.
The row settles with the call.
ok=True -> spent
ok=True, meta.no_effect -> the main class row released; each
secondary row spent
ok=False, retryable=False -> spent; the handler answered
ok=False, retryable=True -> released; nothing happened
the handler raised -> released; the segment retries
the claim answered completed,
processing or conflict -> released; this attempt made no effect
the call timed out -> left reserved; the request may have reached the vendor
Each rule settles every row the call holds, except meta.no_effect. That flag
releases only the row of the main class, because a secondary class can count a
step the handler did make. A LinkedIn send that finds a first-degree person reads
the profile, and LinkedIn counts that view, but it sends nothing.
A row left reserved counts against the cap until its window ends. The ledger can count one unit too many. It never counts one unit too few.
A replay spends nothing a second time. A write or send replay returns from the journal read, before the reservation, so it writes no row. A replay that passes the read and then loses the claim releases the rows it reserved.
The per-second guard is not this check. src/shared/vendor_limits.py caps requests
per second across workers, in Redis. It fails open on a Redis fault, because a short
burst is safe and the vendor's own 429 is the net. A count budget fails closed, because a
vendor that restricts an account keeps it restricted after the outage. The two stay
separate, and neither replaces the other.
A vendor that restricts on count holds the connection for the day. For that vendor a
429, or a refusal that names a limit, is not a short burst. A retry in the same window
gets the same refusal, and each attempt can count against the account. So the handler
never retries it. A vendor that restricts on rate keeps the bounded retry and
retryable_upstream. LinkedIn restricts on count.
a LinkedIn call answers a hold code
-> the handler writes held_until (the next midnight UTC) and hold_reason (the code)
-> the handler raises ConnectionHeldError
-> the invoker answers connection_held, a business failure with no stop
-> the claim completes, and the ledger rows are spent
the next call of a tool with a budget class on that connection
-> the resolver reads held_until with the connection
-> reserve() reads the hold before any slot: budget_exhausted, no ledger row
The call reached the vendor, so its slot is spent. connection_held is not
conflict, because on a send conflict means that the outcome is unknown. A fault in the
hold write does not change the answer. The vendor limited the account, so the call still
answers connection_held, and the log line holds the error class only.
The hold code set is provisional. Any 429, too_many_requests and limit_exceeded
hold the connection. LinkedIn publishes no limits, and the spike recorded no limit
answer. cannot_resend_yet is about one person, so it is a refusal and holds nothing.
The reservation reads the hold, so only a tool with a budget class stops. The refusal
names the clear time in UTC, with meta.partial_reason = 'action_budget_exhausted' and no
stop. A tool with no budget class reserves nothing, so a hold does not block it.
linkedin.invite_status makes no LinkedIn call. linkedin.read_inbox does call
LinkedIn, and it still runs under a hold.
The refusal carries its own stop, beside the code
ToolInvoker answers a refusal it made and a tool that failed as the same type. The two need different answers, and the code cannot separate them. A remote tool may answer budget_exhausted about a vendor budget. A caller that read the code would turn a run that really failed into a partial success.
So ToolResult carries the field stop. Only DefaultToolInvoker writes it, _bound drops a value a handler wrote, and model_payload never renders it.
ToolStop = Literal['budget_exhausted', 'fault']
budget_exhausted the run ends partial under that reason; the work done stays done
fault the run ends failed under the result's own error code
None an answer the model adapts to, or a success
Six refusals on the metered path set fault. Each one is a defect of the deploy or of the call site, and no model can act on any of them. Other internal_error refusals outside that path still set nothing. PLATFORM_STOP_CODES stops a workflow tool node for them, because the code is a member. The agent path reads the field alone, so those refusals return to the model there.
the meter did not answer, or the caller passed an argument accrual cannot read
no accrual checker is wired
the call ran outside a run scope
a write or send tool reached no idempotency journal
policy asked for an approval and no approval service is wired
an approval needs a run scope, and the call carried none
The invoker reports four of the six to Sentry. Those four share one dedup set, so a fan out node reports each one time rather than one time per call.
budget_unavailable also sets fault. It is on the count path, not the metered path. It names a ledger read or write that did not complete.
denied, rejected, invalid_input, tool_unavailable, the five connection codes and a count budget_exhausted set nothing, because each one tells the model to do something else. tool_changed will not either, when the connection resolver lands.
Three callers read it, and each already owns the answer.
| Caller | budget_exhausted | fault | How it leaves |
|---|---|---|---|
WorkflowStepExecutor.run_tool | NodeOutcome.partial_reason | a platform stop under the result's code | it returns the outcome |
AgnoToolAdapter (a read tool) | record_stop → record_ceiling(COST_CEILING) | record_stop → record_failed(ToolStopFault) | the body raises StopAgentRun |
AgnoAgentRuntime._resolve (a write or send tool at the pause) | the same record_stop | the same record_stop | _resolve returns False |
Both agent rows call the one method. A second copy of the mapping would let the two
paths end one run two ways, so AgnoToolAdapter.record_stop is public and holds it once.
ToolStopFault carries the code the invoker named. It is terminal: _answer ends the run
under that code rather than re-raising, because the same wiring answers the same way on
every replay.
⚠️ The drain loop is the third caller, and it is the one a reader forgets. A write or send tool never reaches the adapter body. The framework pauses it, and the drain loop invokes it outside anything the framework wraps. A refusal delivered as that call's result lets the model propose the same send on the next turn, and the run then ends on max_agent_turns under limit_reached, which names the wrong clock. record_stop is public for that reason. Both paths call the one method, so one refusal ends one run one way.
The claim ends with the call
ok=True -> complete the claim with the stored result
ok=False, retryable=False -> complete the claim; the answer is stable
ok=False, retryable=True -> release the claim; nothing happened
the handler raised -> release the claim; the segment retries
the call timed out -> HOLD the claim; nothing is known
A retryable failure must not be remembered. A vendor 429 makes no effect, and a completed claim would return that 429 to every later attempt in the same run. The agent would then be locked out of a vendor that recovered a minute later. See idempotency.
A vendor that restricts on count is the one exception. It does not recover a minute later, so its 429 completes the claim and holds the connection until the end of the UTC day. See a budgeted call reserves a count.
A release is safe because the claim is taken before the handler runs. Nothing outside the platform saw the call.
⚠️ A timeout is the one ending that release does not fit, so it is its own row. The other four are knowable. A success and a stable failure are what the handler observed; a 429 is a rejection the handler read; a raise is a decision the handler made. A timeout is none of those: ToolInvoker cancelled the handler mid await, and the request it had already sent may still reach the vendor. Release it and the very next attempt sends the second email.
So a timed-out call completes nothing and releases nothing. The lease is what bounds it.
retry inside the lease the claim reads `processing`, the invoker raises,
and Inngest replays the step after the lease
retry past the lease the claim is reclaimed, exactly as it is for a
worker that died mid call
The second line is a real duplicate-effect window, and it is the same one a crashed worker already has. Only a downstream vendor idempotency key closes it, which is why passing the key downstream matters for a send.
The deadline case is the opposite answer, and it is also a TimeoutError. _execute refuses before the handler runs when the Run is already past its deadline. Nothing outside the platform saw that call, so it releases. The two are told apart by type, not by message: the pre-call refusal raises a subclass, and the invoker branches on it before the general timeout.
The approval is a pause, not a raise
A write or send tool is declared external_execution=True when the adapter builds it.
Agno then stops its loop before that call runs, and RunOutput.status reads PAUSED.
The declaration is static, and it says only "this call is worth a decision". The decision
stays live: the runtime reads the pending call at the pause and invokes it with the
arguments in hand.
model proposes email.send(to='jo@acme.test', ...)
-> Agno pauses. The callable never runs.
-> RunOutput.status = PAUSED, and each proposal is a RunRequirement
-> the runtime reads requirement.tool_execution.tool_args
-> ToolInvoker.invoke(...) <- our code, at the pause, outside the loop
allow -> set_external_execution_result(<the ToolResult>)
deny -> set_external_execution_result(<a rejected ToolResult>)
require_approval -> ApprovalRequired; the segment returns needs_approval
-> acontinue_run(...), unless the segment stopped
The expiry still travels with the raise. AgentExecutionResult.approval_expires_at is
what the Inngest wait computes its timeout from, and reading it back from the approval row
afterwards would be a second query for a fact the raiser already held.
⚠️ The raise leaves the span block. It must not close the span error. The
span recorder
closes status = 'error' for every exception that crosses its context manager, and it
publishes span.failed. An approval is not a failure. Every gated write
would then read as a fault in the tree, beside the denials that close error
on purpose, and an auditor could not tell the two apart. Catch the raise inside the
block. Record the approval id as an attribute. Close the span ok. Then raise again,
outside the block.
external_execution is what keeps the call in ToolInvoker. Agno's other pause,
requires_confirmation, runs the callable itself once confirmed, which would move every
write off the invoke path. With external_execution the platform executes the call and
hands back a result, so ToolInvoker keeps the idempotency claim, the connection, the
metering and the result boundary. Reserve requires_confirmation for a callable that
genuinely belongs to Agno.
A read tool is not declared at all. It stays an ordinary Agno callable that the adapter runs inside the loop: there is no decision to make, so there is no reason to pay a pause for it. That split is what keeps the handler failure rules alive.
Measured on Agno 2.5.10 (ENG-2093),
against ac-python-api/scripts/spikes/eng2093_agno_native_pause.py:
| Question | Answer |
|---|---|
| Does the pause reach the caller? | Yes. status=PAUSED, and every proposal from the turn arrives as a RunRequirement naming the tool and its exact arguments |
| Does the allow path cost a model turn? | No. 3 provider requests paused, 3 unpaused. The continue makes the one model call the unpaused loop would have made anyway |
| Does Agno ever run the callable? | No. The tool body carried a sentinel Agno never reached. One effect, recorded by the invoker |
| Does a denial come back as a value? | Yes. The refusal is delivered as that call's result, zero effects, and the model adapts and says so |
| Does it survive a process boundary? | Yes. acontinue_run(run_id=, session_id=, requirements=) finished the loop in a process that never saw the pause |
⚠️ requirements is not optional on the run_id path. Omit it and Agno looks for a
resolved admin approval row and raises ValueError.
The runtime drains its own pauses
Every write and send tool pauses, so a call policy allows pauses too. That pause never
reaches Inngest. execute() loops while the result is paused, resolving each requirement
the invoker answers, and returns only when the loop finishes or a call needs a person.
result = await bridge.consume(agent.arun(
input=segment_input.text,
stream=True, stream_events=True, yield_run_output=True,
))
while result.is_paused:
for req in result.active_requirements:
stop.check_ceilings() # before EVERY call, not once per pause
try:
outcome = await invoker.invoke(req.tool_execution, ...)
except ApprovalRequired as exc:
return AgentExecutionResult(stop='needs_approval', approval_id=exc.approval_id, ...)
except CancelRequested:
return AgentExecutionResult(stop='cancelled')
if adapter.record_stop(outcome): # a marked refusal ends the pass
return _answer(result, stop) # the siblings stay undecided
req.set_external_execution_result(outcome.as_result())
result = await bridge.consume(agent.acontinue_run(
run_id=result.run_id, session_id=str(run_id), requirements=result.requirements,
stream=True, stream_events=True, yield_run_output=True,
))
The continue streams like the first call, and bridge.consume is what makes
both one shape. The bridge drives the event iterator, opens and closes the
llm span, writes the usage row, feeds text_delta, and answers the final
RunOutput. An unstreamed continue writes no heartbeat while a model turn runs,
so the reaper fails a healthy segment at the first write tool. See
the agent runtime.
The drain loop is the second place a ceiling is checked. A paused write call never reaches the adapter, so a run with nothing left would continue for ever on write tools alone.
⚠️ The check sits before every call, not at the top of the pause. Agno holds every proposal of the turn, so one pause can carry ten sends. Checked once, a remainder of one sends all ten and the ceiling stops only the next pass.
A second write proposed after a continue pauses again, and the loop drains that one too. Measured: two sequential sends, two pauses, two effects, and 3 provider requests against 3 unpaused. Agno marks a requirement it has handled, so a resolved one is skipped on every later pass.
One turn may hold several proposals, and each gets its own decision
Agno holds every proposal from the turn, not the first. So the decisions are independent, and a denial does not cost the calls beside it.
requirement 1 email.send(to='jo@acme.test') -> allow -> sent
requirement 2 email.send(to='sam@other.test') -> deny -> rejected result, model adapts
Measured: one effect, the allowed one. The model reported both outcomes truthfully.
A refusal the invoker marked is the one exception. It ends the pass at the call that met it, and the proposals beside it stay undecided. A spent ceiling ends the pass the same way, and for the same reason: the run is over, so a decision on the next proposal buys nothing.
A require_approval in a turn ends the segment. Calls already executed stay
journalled, and the remaining proposals are simply not invoked. They return as
requirements when the segment resumes, and the invoker decides them again with a fresh
reading.
The resume executes the approved call
The runtime executes the approved call. It does not wait for the model to ask again.
This is the one place where relying on the model would be a correctness bug. The model is
not deterministic. It may propose the same call, and it may not. A person who pressed
Approve twenty hours ago would then see an approval marked approved and no email, with
nothing in the tree to explain it.
The approval row already holds everything the call needs.
tool name · exact arguments · arguments hash · idempotency key · run · principal
So the resume path is deterministic, and it is a continue of the paused run rather than a fresh segment.
approval resolved = approved
-> ApprovalService rechecks TTL, authority, arguments hash and target state
-> ToolInvoker.invoke(..., authorized_by=approval_id)
the action checkpoint is already decided; every other step still runs
-> set_external_execution_result(...), then acontinue_run(...)
approval resolved = rejected
-> set_external_execution_result(<a rejected ToolResult>), then acontinue_run(...)
-> the loop continues, and the agent adapts
This is the drain loop with one extra argument, and not a second code path.
The segment after approval rehydrates the paused run, meets the same unresolved
requirements, and walks the same loop. authorized_by is passed for the one
requirement the approval names, and every other requirement is decided live as
before. Written as its own path it would carry its own copy of the ceiling check,
the cancel rule and the requirement bookkeeping, and the two copies would drift.
A requirement the segment already executed before it stopped is met again on the
resume, because the mutation never reached the stored session. The idempotency
claim answers completed and returns the stored result, so the effect happens
one time and the span carries replayed = true.
authorized_by answers the require_approval the person already decided. ⚠️ It skips
no part of the decision. The engine still runs, so stage 1 still reads the grant live and
a deny still refuses: a person offboarded while the run waited cannot execute the call
they approved, and a rule an admin added while they deliberated still stops the effect. It
skips nothing else either — the live tool state, the input schema, the journal read, the
idempotency claim, the connection and the result boundary all still apply.
⚠️ A replay is answered before the checkpoint, and policy is not asked. The journal is read first, so a segment after approval that meets a call it already made returns the stored result. Policy is never asked to decide an effect that already happened. Only a write or a send journals, and the key names one organization, one run, one step and one arguments hash, so a replay is always one run reading back its own effect.
⚠️ The invoker checks that the approval covers the call, and the runtime cannot. One turn can hold several write proposals and a person answers one approval row, which names a tool and an arguments hash. The runtime holds neither, so it hands the same id to every proposal of that pause. An invoker that read the flag as pure trust would send a message a person never saw, on the strength of an approval for a different call.
An authorized_by that does not cover the call is ignored, and never denied. The flag
skips one policy decision, so a flag that names another call skips nothing and the call is
decided live. That call then raises ApprovalRequired again, and the run parks a second
time on its own approval row. A denied here would drop that call permanently. The
id names a different proposal of the same pause, and no later pass carries an id that
names this one.
⚠️ A row that covers the call and reads rejected is the one exception. A person
answered this exact proposal, so the invoker returns a rejected result before the engine
is asked. Deciding it live would file a second proposal and show that person the same card
again. "Covers" means the same four fields authorizes reads — organization, run, action,
arguments hash — so a row naming another proposal of one pause is still ignored.
The claim carries that answer past the segment that received it. A later segment holds
no authorized_by, and uq_approvals_run_id_idempotency_key releases its key on a
terminal row since ENG-2156, so the insert would succeed and ask again. ToolInvoker
therefore reads the rejection of <run_id>:<step_path>:<args_hash> before it files.
⚠️ That key is a claim, and never a proof. It names no tool, and step_path is one
constant for an agent run, so two write tools whose validated arguments hash alike share
one key. The invoker compares action and arguments_hash on the row it reads back. A
guard that trusted the key alone would drop every later call of the second tool with no
card shown, on the strength of a refusal of the first.
A read tool needs no such record. It takes no claim, and AgnoRuntime ends the Run on
its first ApprovalRequired with read_tool_needs_approval: a read runs inside the agent
loop, so there is no pause to carry a decision. A rejected read approval therefore cannot
recur.
⚠️ A later segment is not bound by the rejection the way the handed segment is. The
refusal above runs before the engine, so a rule an admin relaxes to allow does not
release the call in the segment holding the id. In a later segment authorized_by is None,
allow returns before the approval is filed, and the claim is never read. One call can end
two ways in one Run, by the segment that holds the id. ENG-2156 took the person's answer
over the rule on the path where both are known.
The second pause terminates. The segment after approval meets the call that already ran. The journal answers before the policy checkpoint, so that call returns its stored result and the pass reaches the next proposal. Each pass decides one more call, and no effect happens twice.
If the model does propose the same call again in that segment, the journal answers
completed and returns the stored result. So the journal makes the deterministic path and the model path agree,
and the effect happens once either way.
The paused call's result slot is empty, so nothing is edited. This is what the pause
buys over a halted loop. A halt had already written a tool result for that call id
carrying tool_call_error, so delivering the approved outcome meant editing Agno's message
list from our code — exactly the knowledge the AgentRuntime boundary exists to contain.
A paused call carries result=None, and the continue fills it. Measured: the invoker's
result arrived on the call with error=None, on both the allowed and the refused path.
Handler failure
| Failure | Handling |
|---|---|
The output fails its own output_schema | ToolResult(ok=False, code='invalid_output'). It is the remote's defect, not ours, and never internal_error |
| Expected business failure | The handler raises the domain exception. BUSINESS_FAILURES maps it to not_found, invalid_input, conflict, denied or connection_held |
| A 429, transport fault or provider timeout after the bounded retry | The handler raises RetryableUpstreamError. The invoker answers retryable_upstream with retryable=True. A vendor that restricts on count is the exception: see a budgeted call reserves a count |
| An external resource is absent or unsupported | The handler raises UpstreamUnavailableError. The invoker answers unavailable with retryable=False |
| Platform fault, such as a credential, the database or the meter | The handler raises. The segment fails, and Inngest retries it |
| Any other exception | The handler raises, exactly as the row above. Nothing on this path answers internal_error as a value |
A handler under services/tools/handlers/ constructs no ToolError. It returns ok=True, or it raises. test_no_handler_constructs_a_tool_error holds that rule, because internal_error is a PLATFORM_STOP_CODES member and a handler that answered it would fail the whole run.
RetryableUpstreamError and UpstreamUnavailableError are neutral handler exceptions. They carry no vendor SDK type and no ToolResult. A vendor handler catches the provider class and raises one neutral class. ToolInvoker owns the stable error code and the retry flag. UpstreamUnavailableError maps to unavailable; tool_unavailable remains the code for a tool that is absent from the live registry.
Provider authentication, payment, request-shape and response-decode failures are not neutral upstream outcomes. A platform credential or a client contract is wrong, so the handler lets the error raise. A segment retry can then reach the normal retry limit and the span keeps the real fault. A decoded handler result that fails output_schema still follows the invalid_output row above.
A send with no answer raises ConflictError, and conflict then means "the outcome is unknown, do not send again". A send that meets a transport error, a 5xx or a 2xx body of the wrong shape may have reached the vendor. RetryableUpstreamError would release the claim, and the next attempt would send a second time. So the handler raises ConflictError. The invoker reads it as a business failure: it completes the claim with that answer and spends the ledger rows. A replay of the call then reads the stored answer and sends nothing. The message tells the model to read the current state before it sends again. A 4xx is a refusal, so the vendor took no send, and the ordinary rows of the table apply.
The vendor retry budget fits inside timeout_s. The client timeout, every retry and every backoff must finish before the tool timeout. If the invoker cancels the handler first, it answers timeout and the handler never reaches RetryableUpstreamError. A declaration review checks the full budget, not one attempt.
An exception never crosses into the Agno loop except for a platform fault, where a segment retry is the correct answer and the journal bounds its cost.
⚠️ The last two rows are one decision, and an internal tool is where it is hard. A CRM read reaches Postgres, so most of what it can raise is the platform fault of row four. Turned into a value, a database outage becomes one failed call among several and the segment ends completed with a confident answer built on nothing — the failure the read path's catch-everything rule exists to stop.
So the invoker splits on the exception, and the list is explicit rather than a judgement:
a business exception the domain declares -> ToolResult(ok=False) with a stable code
NotFoundError, ValidationError, ConflictError
anything else -> raise; the segment ends and Inngest decides
⚠️ The test is the exact class, never the base class. ACError is the root of the AgencyCore error hierarchy and the wrapper every CRM service raises from its own except Exception — list_people answers ACError('Failed to list people') for a Postgres outage. So isinstance(error, ACError) reads a database fault as a business failure, which is the whole defect written as one convenient line. Match the leaf classes and let the base raise.
A handler that catches a database error and answers a value has moved a platform fault into row five by hand. Reviewing for that is the one thing a reader of a new handler must do.
Where the fault is raised decides how it leaves. A write or send tool pauses, so its
ToolInvoker call happens in execute()'s drain loop, outside anything Agno wraps. Those
raises are ordinary raises, and the runtime classifies each one. Nothing is swallowed,
because nothing crosses the loop.
ApprovalRequired -> stop = needs_approval, with the approval id and its expiry
CancelRequested -> stop = cancelled
any other exception -> propagates; Inngest replays the step
CancelRequested needs its own row. A person pressed stop, and a raise that reached
Inngest would land as a failure and overwrite that.
A read tool still runs inside the loop, and inside it the old measurement holds. Agno
turns every exception raised in a tool into a tool result and continues. A database outage
would become one failed call among several, and the segment would end completed with a
confident answer built on nothing. So the adapter catches every exception a read tool's
invoker raises, and it classifies rather than re-raises. It re-raises one, and the table
says which.
a result carrying stop -> record_stop() -> SegmentStop(ceiling|failed) -> StopAgentRun
CancelRequested -> SegmentStop(cancelled) -> StopAgentRun
StopAgentRun -> re-raised; the framework honours this one
any other exception -> SegmentStop(failed, error=exc) -> StopAgentRun
⚠️ The StopAgentRun row is load-bearing. StopAgentRun is an Exception, so a guard
that caught every exception would catch the stop the adapter itself raised. It would then
record that stop a second time and re-raise it under the fault message.
SegmentStop carries ceiling | cancelled | failed. needs_approval left it with the
pause: an approval is decided where the loop is already stopped, so there is no signal to
smuggle out. AgnoAgentRuntime re-raises the stored exception after the loop returns, so
Inngest still sees a real failure and still decides the retry.
⚠️ Read the stored stop before RunOutput.status. StopAgentRun ends the loop and
still answers a normal RunOutput, and a write tool proposed in the same turn makes that
status read PAUSED. A runtime that checks the pause first drains a segment that already
stopped.
Agno's own cancellation manager is not used. agno.run.cancel holds one manager in a
module level global, so two segments of two organizations in one worker share it. The
platform reads agent.run_control at the adapter instead, which is per segment and
durable.
AgnoToolAdapter
The adapter holds no business logic.
read tool
ToolSpec + ToolInvocation -> Agno callable
-> model proposes arguments
-> ToolInvoker.invoke(...) inside the loop
write or send tool
ToolSpec + ToolInvocation -> Agno callable, external_execution=True
-> model proposes arguments
-> Agno pauses; the callable never runs
-> ToolInvoker.invoke(...) in the drain loop
The adapter declares the name a vendor accepts. The name bullet states the pattern. model_tool_name() replaces every character a vendor refuses with an underscore, so email.send reaches the model as email_send. It lives in models.py, beside NAME_PATTERN, because registration reads it too and services may not import runtime. The adapter holds the reverse map, which covers one segment because build() clears it.
The derivation is lossy, so registration owns the refusal. It replaces a character and never removes one, so two platform names can answer one model facing name. NAME_PATTERN allows one dot, so the pair reachable today is crm.search_people and crm_search.people, which both derive crm_search_people. The MCP phase widens the pattern and adds mcp.slack.send_message against mcp.slack_send.message. An mcp.<connection_slug>.<remote_name> can also exceed 64 characters.
build_registry therefore refuses a derived name that collides with a registered one, and a derived name over MAX_MODEL_TOOL_NAME_LENGTH characters. Truncation is not the answer, because it maps two names onto one again. The adapter refuses a collision again when it builds a segment, because it reads the frozen run snapshot and not the registry. An overwritten contract sends the right arguments to the wrong handler.
If the agent framework changes, the adapter is replaced. The registry, the invoker, the policy and the handlers do not move.
Result contract
class ToolError(BaseModel):
code: str
message: str
retryable: bool = False
class ToolResultMeta(BaseModel):
count: int | None = None
truncated: bool = False
untrusted: bool = False
usage_id: UUID | None = None
partial_reason: str | None = None # which ceiling refused the call; the invoker writes it
no_effect: bool = False # the handler is certain that no effect reached the vendor
# What the run does about a refusal the invoker made. Only the invoker writes
# it, and no caller renders it to a model.
ToolStop = Literal['budget_exhausted', 'fault']
class ToolResult(BaseModel):
ok: bool
data: Any | None = None
error: ToolError | None = None
meta: ToolResultMeta = ToolResultMeta()
stop: ToolStop | None = None
Stable error codes:
denied invalid_input connection_required
rejected tool_unavailable connection_expired
not_found tool_changed connection_revoked
invalid_output unavailable connection_ambiguous
conflict retryable_upstream connection_unavailable
internal_error batch_too_large timeout
budget_exhausted budget_unavailable connection_held
outside_send_window
meta.no_effect frees the main count slot of an answer that changed nothing. A handler sets it only when no request that acts reached the vendor, such as a LinkedIn send to a person who is already a first-degree connection. The invoker reads it from the handler answer before the boundary rebuilds meta, and releases the row of the main budget_class only. The claim still completes on the answer, so a replay reads it back. A vendor refusal reached the vendor, so it leaves the flag false.
parent_cancelled is not in this list. A cancelled parent is refused by RunManager.start() before a child run exists, so it fails a workflow node and never reaches a model as a tool result. See runtime execution.
The model never sees a provider exception, a credential or an unbounded payload.
Output boundary
handler result
-> validate the output schema
-> remove platform credentials and tokens
-> keep at most max_results items, when declared
-> bound the size to max_output_bytes or the platform limit
-> tag external content as untrusted
-> set meta.count and meta.truncated
-> span
-> agent
Bounding is bound(output, 32 KB), and it never cuts a structure. The algorithm is defined once in the platform contract. This layer owns two things around it: meta.truncated comes from what bound() returns, meta.count stays honest, and redaction runs first, so a credential is removed by rule rather than by which item happened to be dropped.
The item bound runs before the byte bound. max_results applies only to an array output. It keeps the first items in order. max_output_bytes is at least 2, because [] and {} each need 2 bytes. It can lower the 32 KB platform limit and can never raise it. meta.truncated is true when either bound changes the payload. meta.count names the items that remain after both bounds.
An oversized scalar uses a Dropped marker when the marker fits. If a per-tool byte limit is too small for the marker, the boundary returns an empty JSON string and sets meta.truncated=true. The empty string is the smallest JSON scalar and fits the two-byte minimum.
output_schema validates the handler answer before the boundary. The bounded transport form can contain a Dropped marker or fewer items than the schema's minItems. The marker is one reserved platform envelope, and it is not copied into every node of every tool schema. A caller must use meta.truncated and handle the marker. Registration checks that max_results applies to an array, but it does not compare the limit with minItems.
The substitution reads the item it replaced, at the same position. bound() replaces every oversized item before it drops anything, and it replaces in place, so index i of the answer holds the marker for index i of the input, and a dict keeps its key. The invoker walks the two together and names the row from spec.item_kind and the item's own id.
The ref goes inside the marker, and does not replace it.
{"__dropped__": {"reason": "too_large", "bytes": 40960, "ref": {"kind": "crm.person", "id": "p-2"}}}
A bare ResourceRef sitting among real rows reads as a row: the model receives {"kind": …, "id": …} in a list of people and has no signal that an item is missing, or why. One sentinel key keeps the reason, the size and the pointer together, and __dropped__ is still the one lookup that finds it.
Two cases keep the marker with no ref. A spec that declares no item_kind names no kind, and an item that carries no id names no row. A ref built from a guess resolves to nothing, and a reader cannot tell that from a row a principal may not see.
⚠️ The naming can push the payload back over the limit, so the boundary bounds twice. A ref adds about sixty bytes per marker, and bound() ran before the naming. Measured on four hundred oversized rows: 42,691 bytes against a 32,768 limit, so the caller would store what it promised not to. The second call is the same function, not a second rule, and it drops nothing for a payload that already fits.
Redaction is a key name rule, not a value scan. A field whose key is a credential name — token, api_key, secret, password, authorization, and the two OAuth pair members — is replaced wherever it sits in the payload. Value matching would need a catalogue of every credential shape a vendor mints, and it removes a real body the first time a person writes about a password.
The span input is redacted too, and it is the arguments. The model wrote them. The span recorder bounds a span payload and relies on this layer having redacted it, so the invoker redacts the arguments before it opens the span, not after.
⚠️ One encoding measures the payload and renders it. Two encoders sat on this path and disagreed. The boundary measures with the database body encoder, which writes an Enum as its value, a datetime as ISO-8601 and compact separators. ToolResult.as_result() rendered with json.dumps defaults and default=str, which writes "Stage.ACTIVE", a space separated date, and two extra bytes per separator. The model read the second of each pair, and on 5,000 bounded rows it read 37,312 bytes against a 32,768 ceiling. So the boundary normalizes every value to plain JSON in its own walk, and the rendering uses the separators the measure used.
Every value the boundary answers is renderable, and the rule is total. The measure raises TypeError on a value it cannot encode, and that raise ends the segment — a set field and a bytes field each did it. Anything that is not a JSON scalar is therefore named rather than carried: "<unrenderable bytes>". A list of allowed types grows with every handler; this rule does not. A set becomes a sorted list, because the order of a string set moves with PYTHONHASHSEED and two workers would otherwise render two strings for one row.
Untrusted content is labelled, not scanned. V1 ships no prompt injection detector, and a detector we do not have must not appear in a design. The boundary does three things: it strips control characters, it bounds the size, and it sets meta.untrusted so the adapter wraps the body in a labelled envelope. The real defence is structural. External text can propose an action, and every action still needs a tool call that policy decides. A tool result never carries an instruction to the runtime.
Sensitive fields stay out of the output schema. V1 has no per tool redaction language. A field the model must not see is absent from output_schema.
Batch limits
max_batch_size is per call. batch_argument names the top-level list it measures. An oversized call is rejected after input validation and before the journal read, and it is never silently split. It answers batch_too_large. It reaches no policy read, claim, handler, preview, or meter.
ToolSpec.max_batch_size what one call supports
Policy count and cost what a run or an account may do
Count budget what one connection may do in a UTC day or an ISO week
Vendor wrapper vendor pacing and bounded 429 retry
Splitting is agent or workflow coordination. It is not tool infrastructure. A vendor rate limit is absorbed by a bounded retry inside timeout_s, and then returned as retryable_upstream. The platform adds no second rate limiter. A count budget is not one: it is a ceiling over a window, it lives in Postgres, and it fails closed. A vendor that restricts on count gets no retry, and its 429 holds the connection for the day.
A vendor ceiling is arithmetic, not a limiter
max_batch_size bounds one call, and Inngest concurrency bounds the runs. Neither bounds one vendor across every live run. Two hundred runs each making a legal call can still cross an Exa or a FullEnrich account limit at the same second.
V1 does not answer this with a global semaphore. A semaphore is a second limiter, it needs Redis on the correctness path, and it fails in the one case it exists for, a worker dying while holding a slot.
V1 answers it with a size, chosen once and written down.
sum for each lane(
active Run limit in that lane
x maximum simultaneous calls to this vendor in one active Run in that lane
) <= the vendor account limit
Both terms are ours. Inngest sets the active Run limit per organization and lane. Within one Run, one wide node contributes its fanout_concurrency. A sequence and a branch take the largest reachable value. A parallel adds the values of its branches.
A child Run counts in its own lane. A suspended parent holds no slot, so the arithmetic never counts a child under its parent Run or its parent's lane. Add the interactive and batch lane products to get the account maximum.
This is a configuration review beside the vendor credential, not a run-time mechanism. Reading one node's concurrency would undercount two wide nodes in parallel. Validation still checks each node against the step budget. The bounded 429 retry handles short bursts that pass the account arithmetic.
Record the arithmetic beside the vendor credential, and check it when either number changes.
The escalation, when a vendor gets tight enough to need one: move that vendor call into its own Inngest function, keyed on the vendor name, and call it with step.invoke. Inngest then owns the ceiling, exactly as it owns run concurrency, and the platform still holds no limiter of its own. Do not build it before a vendor forces it.
Connections
ToolInvoker -> ConnectionResolver -> credential, by credential_kind -> vendor or MCP server
One row in agent.connections is one external account that one person linked, inside one organization. The person owns it. The organization scopes it. An organization never holds an account of its own.
agent.connections
id, organization_id
provider 'linkedin'; a new provider is a migration
credential_kind 'platform_key_account'
external_account_id the vendor's id for the account; never blank
owner_user_id the person who owns the account; never null
status connected | needs_reauth | revoked
display_name
metadata what the provider reported at sign-in: for LinkedIn user_id, the
owner's LinkedIn id, is_premium and proxy.country
held_until the end of a day hold, or null
hold_reason the vendor code that wrote the hold; set when, and only when, held_until is set
send_window_start the first local time a send may leave, or null for the default
send_window_end the local time the window closes; both ends are set or both are null,
and the start is before the end
created_at, updated_at, revoked_at revoked_at is set when, and only when, status is revoked
The owner and the organization admins read the four hold and window columns through the column grant. Only the service role writes them.
The credential kind says where the credential comes from.
credential_kind | Credential | Vault reference |
|---|---|---|
platform_key_account | The platform key from the deploy, and the account id from the row | null |
vault_secret | A secret per connection, from the vault | set |
platform_key_account is the kind the schema admits today. The vendor holds the account session, and the platform calls it with one platform key and the account id. vault_secret is the existing design for a credential the connection holds itself, such as the Nylas grant of one person's mailbox, or an MCP token. It joins the check constraint when the vault lands.
external_account_id is authority, so no member can read it. The authenticated role has no grant on the column, and no route returns it. With the platform key, the id is enough to act as the account. The owner reads the row, and an organization admin reads every row of the organization. Another member reads none. Only the service role can write it.
One external account belongs to one organization. A partial unique index on (provider, external_account_id), over the rows that are not revoked, holds that. An inbound webhook names the account and no organization, so this index is what lets the webhook producer find the tenant. A revoked row leaves the index, so the account can link again. The revoke also removes the account at Unipile, so the same LinkedIn login can start a new hosted sign-in; see surfaces.
One person holds one live account of each provider in each organization. A second partial unique index on (organization_id, owner_user_id, provider), over the rows that are not revoked, holds that. The owner's row goes when the owner's profile goes, because nobody else may act as that account.
Each provider has a rollout gate. LINKEDIN_PROVIDER_ENABLED defaults to false. While the gate is off, the resolver answers connection_unavailable for every linkedin connection, and the link and reconnect routes answer 503. The rows stay, and nothing calls the vendor.
Durable lifecycle:
connected -- auth or revocation failure --> needs_reauth
^ |
+--------------- reconnect ---------------+
connected / needs_reauth -- uninstall --> revoked
connecting, refreshing, health checks and retries are operational states. They are not durable product states.
A provider webhook can move the status too. Each move reads the current status first, so a move from a status it does not name changes nothing. For LinkedIn, see the webhook receiver.
A run uses the account of the person it acts for, and never another person's account. The resolver reads only the connections that ToolInvocation.user_id owns, so another person's connection cannot be found, named or used. A run that acts for no person, such as a machine trigger with no user, has no account. The same rule holds for every provider: a LinkedIn account and a Nylas mailbox both belong to one person. A tool may still declare a public connection_id argument, and the resolver then checks that the ID is one of that person's connections. The model never sees the credential.
| Case | Behaviour |
|---|---|
| The person has one live connection for the provider | Use it. The argument is optional |
| The person named one of their own connections | Validate the ID, then use it |
| The ID names another person's connection | Return connection_required, as for an unknown ID |
| The run acts for no person | Return connection_required |
| The person has none | Return connection_required |
| The person has several live connections, and named none | Return connection_ambiguous. The unique index makes this case impossible today, and the resolver still refuses to guess |
The chosen connection reads needs_reauth | Return connection_expired |
The chosen connection reads revoked | Return connection_revoked |
| The rollout gate of the provider is off | Return connection_unavailable |
Each code is a value with no stop, and each returns before any effect. A LinkedIn connection that moves to needs_reauth also cancels the live LinkedIn runs of its owner. See account health. A queued run is not cancelled, and its first LinkedIn call answers connection_expired.
The resolver gives the credential to the handler, and to nothing else. The platform key never appears in a ToolResult, a span payload or a log line, and neither does the account id.
A hosted sign-in uses a one-time link
A person links an account on the provider's own page. When they finish, the provider sends their browser to the platform's callback URL with the account id and a state value in the query, and its signed account.add or account.reconnect webhook carries the same state. Anybody who reaches the callback URL can send any query, and a query the browser carries could name any account. So only the signed webhook binds the account: it comes from Unipile's own servers and names the account the person linked. The state carries a one-time token, and the webhook body is still not trusted on its own.
agent.connection_links
id, organization_id
owner_user_id the person who asked; the connection becomes theirs
provider 'linkedin'
connection_id the connection a reconnect restores; null for a new account
token_hash sha256 of the one-time token; no member can read it
created_at
expires_at 30 minutes after the link was issued
used_at set by the one sign-in webhook that claims the link
The link route writes the row first. It mints a token of 32 random bytes, stores only its SHA-256 hash, and gives the row id and the token to the provider flow. The flow puts both in the state, and it sets the provider link to expire with the row. The token is high-entropy, so a plain hash is enough. No log line, no response and no stored webhook row carries the token.
POST https://api.unipile.com/v2/auth/link
redirect_uri = {API_BASE_URL}/api/v1/agentic/connections/linkedin/callback
state = <link id>.<token>
expires_on = the row's expires_at
providers = linkedin, with the classic product and no product selection screen (new account)
account_id = the account to reconnect (reconnect)
The webhook binds an account to the link, and to nothing else. The webhook receiver applies this rule to each account.add and account.reconnect delivery.
- The state binds the delivery to a link the platform issued. The hash must match, in constant time. A link that was already used answers success and changes nothing. Otherwise the link must be unexpired.
- The platform reads the account back from the provider. For LinkedIn it must be a LinkedIn account whose status is
running. One that is not running yet is not refused: the delivery answers503, nothing is stored, and Unipile sends it again, because one sign-in sends one webhook and a stored refusal would be final. - A reconnect binds only the account of the link's connection. That connection must still be live and belong to the link's owner.
- A new account binds only when no live connection holds it, in any organization, and when the provider created it after the link was issued. When the provider gives no creation time, the second check does not apply.
- One delivery claims the link. A repeat after success changes nothing, so a webhook resend is safe.
A refusal is final for that delivery: the row is stored as dropped and a resend of the event is a duplicate. The link stays unused, so a new sign-in with a new link can still bind. Only the two cases a retry can mend, no answer from the account read and an account that is not running yet, answer 503 and store nothing. The read-back proves that the account exists. The token binding is what proves that the person who holds the link signed in. The receiver then writes the connection for the link's owner, with the metadata the provider reported: the owner's LinkedIn user_id from the account, is_premium from the owner's own profile (GET /v2/{account_id}/users/me, best effort), and the proxy country. Every read runs before the link is claimed, so the claim and the write sit together. A reconnect whose owner read failed keeps the premium flag the row held, so the note cap does not fall on a paid account. See surfaces for the routes and their answers.
The callback only lands the browser. It reads the state, checks the token, and waits up to 6 seconds, reading the link every half second, for the webhook to claim it. It lands with linkedin=connected once the claim is there, linkedin=pending when the wait ends first, and linkedin=failed for an unknown link, a wrong token, an expired link or a sign-in Unipile reported as failed. It makes no Unipile call and writes nothing. The Settings page reads the connection list on focus, so a pending sign-in shows its row when the webhook lands. A person who closes the tab before the redirect still gets their connection. A deploy with no webhook endpoint registered lands every sign-in as pending and binds nothing: register the endpoint before the gate goes on.
The base URL must be one the provider can reach, and the link route checks it first. The browser follows the redirect to any base URL, but every webhook comes from Unipile's own servers. With API_BASE_URL empty or on a loopback host (localhost, 127.0.0.0/8, ::1), the account status webhooks never arrive, so a connection that LinkedIn disconnects stays connected here. So a deploy with the gate on and such a base URL registers no LinkedIn flow. GET /providers reports LinkedIn as not available. The link and reconnect routes answer 503 with a detail that names API_BASE_URL, before any link row is written and before any Unipile call. The API logs one warning at startup. With the gate off nothing changes.
The access log keeps the other parameters and not the state. uvicorn writes one access line per request with the full request target, so the callback line would hold the token. A filter on the uvicorn.access logger replaces the state value of a request on the callback path with [scrubbed] and leaves every other path unchanged. The app installs it at startup. The production Procfile runs uvicorn at --log-level warning, which silences the access logger; the development Procfile runs at info and prints every line. The filter protects both.
⚠️ The v2 sign-in is provisional. The state echo, the account.add payload and the redirect query come from the Unipile v2 documents, read on 2026-10-26. No live v2 sign-in has confirmed them. The v1 flow, which called a notify URL with a name echo, was recorded twice and is gone.
LinkedIn through Unipile
Unipile acts as one connected LinkedIn account. The platform key and the account id together act as that account. A LinkedIn handler reads the account id from the resolved connection only. The account id never enters a ToolResult, an approval preview or a log line.
LINKEDIN_PROVIDER_ENABLED is the rollout gate, and it is off by default. While it is off, the resolver answers connection_unavailable for every LinkedIn call, so no handler runs. A deploy with no Unipile key builds no client, and every LinkedIn handler then answers unavailable.
⚠️ Every Unipile v2 shape below is provisional. The platform moved from Unipile v1 to v2 on 2026-10-26, on the v2 reference pages alone. The v1 recordings do not apply: v2 renamed every object. The list of provisional facts collects what a live v2 session must confirm.
The client repeats a read, and never a send
src/clients/unipile_client.py calls https://api.unipile.com/v2 and puts the account id in the path of every account call. It refuses to build without the key. Each attempt runs for 30 seconds at most by default. The LinkedIn tools build the client with LINKEDIN_ATTEMPT_TIMEOUT_S, which is 8 seconds, so a send fits its tool timeout. See the send timeout.
GET two attempts; the second follows a 5xx or a transport error
POST one attempt; a 5xx or a transport error leaves the outcome unknown
any 4xx one attempt
A GET only reads, so a second attempt changes nothing. Unipile may have accepted a POST that got no answer, so a second attempt can send to LinkedIn two times. The client raises, and the handler decides. See handler failure.
The nine tools
| Tool | Side effect | Count budget classes | Answer |
|---|---|---|---|
linkedin.read_profile | read | linkedin.profile_reads | The profile, its network distance and any pending invitation. The read reports no visit |
linkedin.invite_status | read | none | Our invitation row, by invitation_id or by the person. It makes no LinkedIn call |
linkedin.read_inbox | read | none | The newest 50 messages of one chat, each with its direction, and replied. The text is untrusted |
linkedin.send_invite | send | linkedin.invitations, then linkedin.profile_reads | sent, adopted, already_connected or failed, with our invitation row id |
linkedin.send_message | send | linkedin.messages, then linkedin.profile_reads | sent with the chat id and the message id, not_connected or failed |
linkedin.read_posts | read | linkedin.profile_reads | The recent posts of one person, newest first, with the text, the link, whether each is a repost, and the counts. The text is untrusted. The v2 post shape is mapped from the reference, not a recording, so the date, the counts and the author can be empty |
linkedin.publish_comment | send | linkedin.comments | posted with our engagement row id and the comment id, or failed with the code. The comment id can be empty: the v2 answer carries it under a field the provisional mapping does not read |
linkedin.add_reaction | send | linkedin.reactions | reacted with our engagement row id, or failed with the code |
linkedin.visit_profile | send | linkedin.profile_reads | visited or failed with the code, and the profile. Per the v2 reference, LinkedIn shows the person the visit; no live visit has confirmed it |
Each send declares approval_preview and repeatable = False. The preview names the person by the public identifier of the profile URL, and quotes at most 200 characters of the note or the text. Four of the five sends have their own require_approval rule in every organization: invitations, messages, comments and reactions. linkedin.visit_profile has none. The migrations write them for the organizations that exist, and organization creation writes them for a new one. linkedin.read_inbox calls LinkedIn only when a conversation row proves that the chat belongs to the connection. An unknown chat id answers not_found.
linkedin.visit_profile reads the profile, then reports the visit. The v2 profile read answers specifics.notify_visit_token. The handler sends the token to POST /v2/{account_id}/users/visit-profile?token=... just after the read. Per the v2 reference, LinkedIn then lists the visit for the person, as it does for a visit in the LinkedIn app. No live visit has confirmed it; see provisional facts. The viewer's own profile viewing setting decides whether the person sees the viewer's name. A read with no token answers failed with visit_not_supported, and a visit LinkedIn refuses about the person answers failed with the code. A visit with no answer, a 5xx or a transport error may have reached LinkedIn, so it answers failed with visit_unconfirmed: the call completes and a replay does not visit again. A refusal by the Unipile limiter raises a retryable error, because nothing reached LinkedIn. The read is the one count, so every answer spends it. The tool keeps the send window and has no default require_approval rule, because it sends no text. A rule an admin adds for it makes every automated profile view step skip, because the Sequences app answers no approval for a visit. It waits no jitter, and its 40 second timeout holds one read with its retry and one visit attempt. A first-degree read accepts the open invitation, as linkedin.read_profile does.
linkedin.send_invite reads the profile before it sends. The send needs the provider id, and the profile answers two cases with no send. A first read that would stop the send is confirmed by a read by the person's id, and the read by id decides. A read by public identifier answered FIRST_DEGREE ten minutes after the person removed the connection on 2026-10-27, while a read by id answered SECOND_DEGREE. The confirmation is a second profile view that no reservation counts, and the common path still makes one.
read the profile by public identifier
FIRST_DEGREE or a pending request -> read the profile again by id; that answer decides
FIRST_DEGREE -> already_connected, meta.no_effect
a SENT, PENDING invitation -> adopted, meta.no_effect; our open row, or a new adopted row
apply the free-account note cap
send the invitation
201 -> a sent row
a documented refusal -> a failed row that stores the code
already invited -> adopted
no answer, 5xx, bad 2xx body -> read the profile again
a pending invitation -> adopted
anything else -> ConflictError
The profile read after a send with no answer is not counted by any reservation. The answer carries our invitation row id, because a later wait correlates on it. It never carries the Unipile invitation id.
linkedin.send_message sends only to a first-degree connection. With a chat_id, the chat must be a conversation of the connection, and no profile is read. The profile read slot still counts. With no chat_id, the handler reads the profile. A person who is not FIRST_DEGREE on that read is read again by id, as in linkedin.send_invite, and a person who is not FIRST_DEGREE on the read by id answers not_connected with meta.no_effect. Otherwise the handler sends in the chat the connection already holds with the person, or it starts a chat with POST /v2/{account_id}/inboxes/CLASSIC_PRIMARY/chats/send. LinkedIn starts a chat only from the primary inbox of a product; the generic chats/send path answered 501 api/not_implemented on 2026-10-27.
The send answer and the webhook echo carry the same message id, so the message id is the dedupe key. After a sent message, a store fault is logged and not raised. A raise would release the claim, and the retry would send the message again. The webhook echo can still write the row.
The error map is provisional. No live v2 call has recorded a LinkedIn refusal. Unipile v2 writes each error type as <thrower>/<code>: api when Unipile refused the call, provider when LinkedIn did and Unipile forwarded it. A failed row and a hold store the bare code, which the Settings page and the skills read. A LinkedIn code wins over a documented type. A provider/unprocessable_entity whose title or detail spells a LinkedIn code, with underscores or with spaces, takes that code, because v2 documents no per-person type and the code may travel in the text.
| Unipile v2 type | Outcome |
|---|---|
api/too_many_requests | Unipile's own limiter, with a retry-after header: retryable_upstream |
provider/too_many_requests | a LinkedIn limit: the handler holds the connection for the day and answers connection_held |
provider/invalid_authorization, provider/invalid_credentials, api/inactive_subscription, api/account_restricted, api/insufficient_permissions | unavailable |
provider/unprocessable_entity, provider/insufficient_permissions, api/conflict, provider/conflict | a refusal: the send stores the type on a failed row and answers failed |
api/resource_not_found, provider/resource_not_found | not_found |
api/invalid_parameters, provider/invalid_parameters | invalid_input |
A refusal and a not_found are answered as a value where the caller can act on the person or the post. In the profile read that each send makes first, a provider/insufficient_permissions or a */resource_not_found does not raise: the send answers failed with insufficient_permissions or resource_not_found and sends nothing. linkedin.publish_comment and linkedin.add_reaction answer a refused or missing post the same way. Everywhere else the two outcomes raise as the table says.
The LinkedIn refusal codes that v1 documented are matched by their code alone, under any thrower, in case v2 forwards them:
| Code | Outcome |
|---|---|
already_connected | the send answers already_connected |
already_invited_recently | the handler reads the profile again. A pending invitation answers adopted. Any other state is a refusal with this code, because LinkedIn also answers it for a recent invitation that ended |
cannot_resend_yet, cannot_resend_within_24hrs, cannot_invite_attendee, invalid_recipient, user_unreachable, blocked_recipient, not_allowed_inmail | a refusal |
no_connection_with_recipient | the message send answers not_connected |
too_many_characters | invalid_input |
disconnected_account | unavailable |
limit_exceeded | the handler holds the connection for the day |
With no matching type or code, the status decides: a 429 holds the connection; no response or a 5xx is retryable_upstream, except a send, which raises ConflictError; a 401, a 403 or a 2xx body of the wrong shape is unavailable, except a send with a bad 2xx body, which raises ConflictError; a 404 is not_found; any other 4xx is a refusal.
A hold code on any LinkedIn call holds the connection: a read, the profile read of a send, or the send. A hold code on the profile read after a send with no answer writes the hold, and the answer stays conflict, because the send may have reached the person. See a vendor that restricts on count.
A send runs only inside the send window
linkedin.send_invite and linkedin.send_message declare send_window = True. The invoker checks the window after it resolves the connection and before it reserves the count budget.
window send_window_start to send_window_end on the connection;
07:00 to 20:00 when both are null
the start is inside the window, and the end is not
zone profiles.timezone of the owner, then organizations.timezone, then UTC;
a failed read or an unknown zone name reads UTC
outside ToolResult error 'outside_send_window', no stop, no slot, no claim
The resolver answers only a connection of the person the run acts for, so that person is the owner. The zone chain is the one the dashboard reads for its "due today" window. The refusal message names the window, the zone and the local time now, and it tells the model to send again after the start or to ask an admin. A window does not cross midnight.
An admin widens the window on the connection. PATCH /api/v1/agentic/connections/{id} sets both ends, or clears both to restore the default. It needs policy.write, the same authority as the count budget writes. The CLI command is ac agentic connections set-window <id> --start 06:00 --end 22:00, or --clear. See surfaces.
Each send waits a random 5 to 60 seconds
Each send handler waits a random time from 5 to 60 seconds after the profile read and just before the vendor send. Two sends therefore never leave at a fixed interval. The linkedin.send_jitter report carries slept_s to the tool span, under capability_reports.
An answer that sends nothing does not wait, such as already_connected or not_connected. A replay returns from the journal before the handler, so it never waits.
A send fits inside 110 seconds
Each send runs under timeout_s = 110. The worst case of one send that still sends:
profile read 2 attempts x 8 s + one 0.5 s wait 16.5 s
read by id the same, when the first read 16.5 s
would stop the send
jitter at most 60.0 s
send 1 attempt x 8 s, never repeated 8.0 s
------
101.0 s
The 9 seconds that remain hold the store reads and the 2 second return margin. 110 seconds is inside the 120 second step budget. A send with no answer reads the profile one more time, for up to 16.5 seconds, and that read can pass 110 seconds. The invoker then cuts the call and holds the claim, which is the same unknown outcome. test_linkedin_declarations.py computes the sum from the constants.
The 8 second attempt timeout is provisional. The spike measured profile reads at 2.1 to 3.3 seconds and sends at about 1.3 seconds, on one account in one afternoon. 8 seconds is about 2.4 times the slowest call seen.
The status model has no read status
The LinkedIn rows are the record of each LinkedIn effect and of each reply.
| Row | Values | Written today |
|---|---|---|
Invitation, agent.linkedin_invitations.status | sent, accepted, ignored, withdrawn, unknown_terminal, failed | sent and failed by the send. accepted by a profile read that shows a first-degree connection, with accepted_via = 'profile', or by a new_relation webhook, with accepted_via = 'new_relation' |
Outbound message, agent.linkedin_messages.status | sent, replied, failed | sent by the send or by the echo of a manual send. replied by a reply webhook |
| Inbound message | no status | by a reply webhook |
There is no pending value. A sent invitation with no later value is pending. An adopted invitation is a sent row with adopted = true.
A profile read is the first acceptance source. linkedin.read_profile, and the profile read in each send, can show network_distance = FIRST_DEGREE. The handler then sets the open invitation to the person to accepted, with accepted_via = 'profile'. It finds the row by the provider id, then by the public identifier. Only a sent row changes. A store fault does not fail the read. The new_relation webhook is the late backstop, because it can arrive up to 8 hours later.
The schema admits ignored, withdrawn and unknown_terminal. No path writes them yet.
No read status exists. No row holds seen or seen_at, no tool answer carries seen, and no webhook subscribes to message_read. A reply is the signal.
A reply does not prove an acceptance. A person can reply to the note of an invitation without accepting it. A reply sets replied_at on the open invitation, and the invitation keeps its status.
The note chat is a hint, and today it does nothing. When a person accepts an invitation that had a note, LinkedIn starts a chat whose only message is our note. The webhook delivers it as our own message in an unknown chat. A v2 message names no other participant, so the receiver cannot tell whose chat it is, and it drops the delivery.
The tool answers add three values that are not row values: adopted, already_connected and not_connected.
A free account sends a few notes each month
LinkedIn lets a free account send only a few invitations with a note in one month. The rule and the cap are provisional.
free is_premium in the connection metadata is absent, false or malformed
paid is_premium is true
cap 5 noted invitations in the UTC calendar month, on the connection
The hosted sign-in reads is_premium from the owner's own profile. The v2 account object carries no premium field.
An unknown value counts as free, so the cap applies when the platform does not know. The send counts the invitations of the month with note_sent = true. At the cap, the send goes with no note. The row stores note_sent = false, and the answer carries note_dropped = true. The ledger has no class for notes.
The webhook receiver stores each delivery, then raises
POST /api/v1/agentic/webhooks/unipile
1. the LinkedIn rollout gate is off -> 404
2. the body is larger than 64 KB -> 413
3. the unipile-signature header is missing or wrong -> 401, no body
4. the receiver scopes, stores and applies the delivery -> 200, also for a dropped chat
5. a store or a raise failed -> 503, Unipile sends again
The route has no user token. Unipile v2 sends every event of the application to one endpoint and signs each delivery with the endpoint secret: the header is unipile-signature: t=<unix seconds>,v0=<hex>, where v0 is the HMAC-SHA256 of <t>.<raw body>. The route recomputes it over the bytes as they arrived, compares in constant time, and refuses a timestamp older than 300 seconds. An empty secret setting refuses every delivery. Nothing is stored before the signature matches. The route removes the signature header from each Sentry event of its request, and the receiver stores no header. Unipile asks for an answer inside five seconds and retries a failed delivery after 2 and 12 seconds, so the receiver's Unipile reads run with a 3 second attempt and no retry. A sign-in delivery makes two or three reads and can pass the window on a slow Unipile; the resend then finds its stored row, or lands on the link claim, which one caller wins.
Each delivery is one Event. id, type, account_id, created_at and a payload whose shape depends on the type. The receiver reads message.new, relation.new, account.add, account.reconnect, account.remove, account.locked, account.unlocked and the five account.status.* types. Any other type is stored raw as dropped. The stored row of a sign-in event holds [scrubbed] in place of the state, because the state carries the one-time token.
A message.new delivery passes the inbox scope before anything is stored. A connected account holds the private conversations of its owner, and the webhook delivers every one of them. The platform keeps a conversation only when it has a reason to read it, decided on 2026-10-20:
- the platform holds the chat already, because it started it or kept it before;
- the platform invited the other party on the connection, with an invitation that is
sentoraccepted, found by the user id, then by the public identifier or the slug of the profile URL. A failed, ignored or withdrawn invitation keeps nobody in scope. The reply to a note after the acceptance lands in the note chat, which has no conversation row yet, so this is the rule that keeps it; - the other party is a live CRM person of the organization, matched on the canonical profile URL in
crm_people.linkedin_url, built from the sender'sprofile_urlorpublic_identifier.
The other party is the sender of a message from the person. A v2 message names its sender and no other participant, so an echo of our own send has a known other party only through the chat row: an echo in a chat the platform does not hold is dropped. Any other chat is dropped: no LinkedIn row, no agent.linkedin_webhook_events row, no raw body, one log line with the type and the reason. A relation.new and an account delivery take no scope test. The CRM read also runs for a chat in scope through a row, because the message event carries the person id. The payload does not say whether the chat is a group, so a reply in a chat the platform does not hold reads the chat from Unipile (GET /v2/{account_id}/chats/{chat_id}) before anything is stored, and only when the chat would be in scope. A group chat is dropped: a kept one would take a later private message to the person. A chat Unipile does not hold is dropped too. A read that gets no answer makes the route answer 503, so Unipile sends the delivery again. With no Unipile client, the chat is kept as a 1:1 chat.
The receiver stores the raw delivery first. agent.linkedin_webhook_events.delivery_id is unique, so a repeated delivery inserts nothing and changes nothing. The id is the Unipile event id, as evt:<id>. An unreadable body with no event id takes unipile:sha256:<hash of the body>. A second relation.new for the same person is a new event with a new id, so a second acceptance applies.
The account receiver trusts no status in the body. Each account delivery makes the receiver read the account back from Unipile with GET /v2/accounts/{account_id}, the same read the hosted sign-in uses. The receiver applies the status that the read reports now. A late or superseded delivery reads the current status, so it cannot undo a newer state. For example, an account.status.disconnected retry that arrives after the person reconnected reads running, and the connection stays connected.
A sign-in event ends the hosted sign-in. account.add and account.reconnect carry the state of the link. The receiver applies the binding rule to them before anything else, because the account has no connection yet. A delivery that binds the account is applied. One that binds nothing and names an account no live connection holds is dropped. One for a known account then reads the account back, as any account delivery does. When the account read gets no answer, or the account is not running yet, nothing is stored, the link stays unclaimed and the route answers 503, so the resend binds; a 200 here would end the retries with the account orphaned at Unipile.
The receiver fails closed. An unknown type, an account that no live connection holds and a payload the models do not read are stored raw as dropped, logged one time with the type only, and acknowledged with 200. They change no row. A message.new whose is_sender disagrees with the sender id, judged against the owner's user_id in the connection metadata, is dropped with no row. A store failure while the receiver applies a delivery removes the stored delivery, and the route answers 503. The next delivery then applies again.
When the deploy has no Unipile client, or the account read fails, the receiver stores the account delivery as stored and changes no status. It logs one line with the reason and the Unipile error code, and no account id. The route answers 200. A repeat of that delivery is a duplicate, so it does not read again. The next delivery reads again.
| Type | Effect | Outcome |
|---|---|---|
message.new from the person, in scope | the conversation, an inbound row, replied on our last sent message before it, replied_at on the open invitation, then linkedin.message_received.v1 | applied |
message.new, out of scope | nothing, and no event row | dropped, with no row |
message.new, our own message, whose id a row already holds | nothing | stored |
message.new, our message in a known chat, sent from LinkedIn itself | an outbound sent row, no event | applied |
message.new, our message in an unknown chat, such as the note chat | nothing, and no event row | dropped, with no row |
relation.new | accepted on the open invitation, found by the user id, then by the public identifier. The acceptance time is the relation's created_at, else the receive time. Then linkedin.invite_accepted.v1 | applied |
relation.new with no open invitation | nothing, no event | stored |
account.add, account.reconnect with a live state | the connection, through the binding rule | applied |
| any account type | a read of the account, then the connection status by the table below | applied, or stored when nothing moved |
| The account read | Move | Evidence |
|---|---|---|
status running | needs_reauth to connected | provisional |
status disconnected or partial, or is_locked | connected to needs_reauth, then the follow-up | provisional. A locked account refuses every call |
| 404 for the account | connected or needs_reauth to revoked | provisional |
status errored, degraded, or any other value | no move | provisional. errored is a Unipile, proxy or provider interruption, which a new sign-in does not mend; the calls answer unavailable until the account runs again. v1 moved its ERROR to needs_reauth |
| no client, or the read fails | no move |
Each move is conditional, and no move leaves revoked. The type of the delivery only picks the payload model.
Two platform events, raised after the row. An applied reply raises linkedin.message_received.v1, and an applied acceptance raises linkedin.invite_accepted.v1. The receiver hands a small record to a sink in surfaces/webhooks/linkedin_events.py. The sink mints the PlatformEvent and dispatches it through the two-reader event router of the trigger path. A waiting run resumes, and a trigger may start a run. The envelope id is the delivery id, so a repeated delivery mints one Inngest id. The producer is webhook.unipile.
linkedin.message_received.v1 subject linkedin.conversation <row id>
connection_id, conversation_id, chat_id, message_id, attendee_provider_id,
person_id (the CRM person, or null), invitation_id (the open invitation, or null)
linkedin.invite_accepted.v1 subject linkedin.invitation <row id>
invitation_id (our row id), connection_id, user_provider_id, accepted_via
The event carries ids and never the message text. A model reads a message only through linkedin.read_inbox. A reply to the invitation note raises the message event alone; the invitation wait is not woken by it, and the workflow reads the row.
The row is written before the raise, so a wait that registered late reads the row: see the row reads of a LinkedIn wait. The raise runs inside the same guard as the row writes. A raise that fails removes the stored delivery and the route answers 503, so Unipile sends the delivery again. A retry whose rows an earlier attempt already wrote changes nothing, so it raises nothing; the row reads are the backstop. The dispatch runs on the webhook request, as the provider callback route does, and the receiver takes no IdempotencyService claim: the unique delivery_id of agent.linkedin_webhook_events is its claim. That is the one way it still differs from the webhook producer shape.
A LinkedIn wait reads its row before it registers
Two entries in the wait reader map, beside the provider job entry.
| Event type | match key | The read | Settled when |
|---|---|---|---|
linkedin.invite_accepted.v1 | invitation_id, our row id, which linkedin.send_invite answers | agent.linkedin_invitations by id, in the organization | the status is not sent. An ignored, withdrawn, failed or unknown invitation settles too, because nothing wakes a wait on an ended one, and the workflow reads the row to learn which ending it was |
linkedin.message_received.v1 | chat_id, which linkedin.send_message answers | the one conversation of the organization with the chat id, then its newest outbound message | our newest message in the chat is replied. A chat can hold history, so "any inbound row" is the wrong read. An unknown chat, a chat id two connections hold, and a chat with no outbound message register the wait |
scripts/register_unipile_webhooks.py registers one webhook endpoint for the route, subscribed to the types the receiver reads. Unipile answers the endpoint secret one time, in the create answer; the script prints it, and the operator sets it as UNIPILE_WEBHOOK_SECRET. The script sends nothing without --apply. It lists the endpoints first and creates nothing when one holds the same URL. The registration request shape is provisional.
Account health stops the runs of a connection that needs a sign-in
disconnected, partial and a locked account need a person, so each moves the connection to needs_reauth. The move has a follow-up, and only the move runs it. A replay that finds the row in needs_reauth already moves nothing, so it cancels nothing and notifies nobody.
connected -> needs_reauth
1. cancel each live run of the owner that can use the connection
same organization, acts for the owner, waiting or running,
and its grant holds a linkedin.* scope
RunManager.cancel(run, reason 'connection_needs_reauth', the system actor)
a child whose root is in the same answer is skipped; the root cancel stops the tree
each cancel also cancels the pending approvals of the run
2. notify the owner, and each owner and admin of the organization, one time
type linkedin.connection.needs_reauth
link /settings/connections, labelled Reconnect
key the type, the connection id and the time of the move
A run snapshot names no connection. The resolver binds each call to the person the run acts for, so the person and the grant decide which runs can use the connection. A queued run has made no call yet. It is not cancelled, and its first LinkedIn call answers connection_expired.
Each step catches its own fault, so a failed cancel still notifies. One failed cancel does not stop the others. A fault is logged one time with the step and the error type, and no account id. It does not undo the move, and the delivery stays applied. The app passes the notification producer in at boot. With no producer, the notify step fails and logs.
A reconnect resumes nothing. A cancelled run stays cancelled. The person starts the work again after the reconnect.
The cancel applies to every run, including a sequence run. Whether an Envoy sequence run is an exception, and waits for the reconnect, is an open decision.
The Sequences app sends through runs
The Sequences app is the legacy Envoy engine, src/domains/envoy. It never calls Unipile and never imports a LinkedIn handler. A LinkedIn task step of a sequence carries execution: manual creates the CRM task as before, and automated acts through the sender's own connection. Four subtypes can be automated: the connection request, the message, the post engagement and the profile view. The engine reaches the platform through LinkedInSendPorts in src/domains/envoy/sequences/steps/step_linkedin_sender.py, EngagePorts in step_linkedin_engage.py and VisitPorts in step_linkedin_visit.py, the way the enrichment step reaches people.enrich.
draft the step executor writes the note or the message as an awaiting_approval
crm_communications row of type linkedin, in the outbox, as an email draft
approve the outbox approval claims the draft (status sending, send_attempts 1),
starts one run of the managed workflow linkedin.invite.root or
linkedin.message.root as the draft's sender, records the approver on
communication_ai_metadata.reviewed_by, and moves the recipient step to
draft_approved with linkedin_run_id
poll envoy.process_linkedin_runs, every minute, for each draft_approved step:
run waits on a policy approval -> resolve it as the approver
run succeeded -> settle from the tool answer
run failed on a retry code -> schedule the next attempt
run failed otherwise -> step failed, draft failed
reply envoy.process_linkedin_replies, every minute, ends the recipient
whose sent message or invitation note the person answered
The managed workflows are one tool node each, written by the capability sync under the email_sequences entitlement, with no result projection. A projection reads each field through a reference, and a reference to a null value does not resolve, so a projection would end every run whose answer carries a null id, which is every answer except sent. The run output is the node map, and the poller reads the answer under send. The seven workflows are linkedin.invite.root, linkedin.message.root, linkedin.profile.root (the relation read, answered under read), linkedin.posts.root (the posts read, answered under read), linkedin.comment.root, linkedin.reaction.root and linkedin.visit.root (the profile visit, answered under read). The comment and the reaction take the post id only: the posts read answers no author id, and a reference to a missing input fails the run.
One human decision authorises one send. The organization's require_approval rule for the tool (an invitation, a message, a comment or a reaction) still pauses the run inside the tool call. The poller answers that approval as the person who approved the draft. There is no second review.
The settlement reads the tool answer:
| Answer | Draft | Step |
|---|---|---|
sent, adopted | sent, external_id is our invitation row id or the message id | sent, linkedin_invitation_id on an invitation, then the recipient advances |
already_connected | superseded | skipped, "Already connected on LinkedIn", then the recipient advances |
not_connected | superseded | skipped, "Not connected on LinkedIn", then the recipient advances |
failed with a per-person code (cannot_resend_yet, invalid_recipient, user_unreachable, blocked_recipient, ...) | failed, send_error holds the code | skipped with the reason, then the recipient advances |
failed with a profile code: the profile read before the send answered provider/insufficient_permissions, so LinkedIn does not show the person to this account, or */resource_not_found, so it holds no profile at the URL. The send answers the read as a value, as the error map says, and nothing was sent | failed, send_error holds insufficient_permissions or resource_not_found | skipped with the reason, then the recipient advances |
failed with any other code | failed | failed |
A refusal of the window, the budget or a hold is retried. A run that ends outside_send_window, budget_exhausted or connection_held is not a final answer. The step stays draft_approved, because the status trigger forbids a return to pending, and the draft stays sending. The poller writes the next attempt time on the step's scheduled_for: the next opening of the sender's window, read in their zone as the invoker reads it, for the window (one hour when the window cannot be read), one day for the budget and the hold. The step card counts such a step as queued, and the in-progress bucket of the recipients modal says when it sends. When the time comes it starts a new run with the same input, as the sender, under the idempotency key linkedin-draft:<communication id>:<attempt>, and counts the attempt on the draft's send_attempts. A window refusal is retried 24 times, through one night. A budget refusal or a hold is retried three times, then the step skips with "LinkedIn limit reached". The stale send-claim sweeper of the cleanup job skips type linkedin, because a LinkedIn draft stays claimed for as long as its run waits.
An automated message waits for the invitation. Before it drafts, the step reads the latest invitation an earlier step of the recipient sent and the invitation step's accept_wait_days (1 to 14, default 7). An accepted invitation, or none, lets it draft. A refused or withdrawn one skips the step with "Invitation not accepted". A pending one parks the step for a day at a time, processing -> pending with scheduled_for, until the days have passed. The step then drafts and the send tool's profile read decides: a person who accepted with no webhook is found there, and anyone else answers not_connected. No model call is spent on a message that cannot be sent yet.
A connection request step can hold the recipient. With wait_for_acceptance on the step, the send does not advance the recipient. The step stays draft_approved, because the status trigger allows no other open state after an approval, with the invitation row id, the day the wait ends in scheduled_for (accept_wait_days after the send) and the marker awaiting_acceptance in last_error. The run poller looks after it: every poll reads the invitation row, which costs nothing and catches an acceptance the relation.new webhook reported. A day after the last run, and once more when the deadline passes, it starts one linkedin.profile.root read as the sender, with the profile URL the send used. A row that reads accepted, or a read that answers FIRST_DEGREE, marks the step sent and advances the recipient. A refused, withdrawn or missing row, or the deadline with no acceptance, marks the step skipped with "Invitation not accepted" and completes the recipient: the later steps assume a connection. Each look spends one profile read of the sender's day budget; a read the budget refuses is tried again the next day, and the deadline ends the wait whatever the budget. Off, the request sends and the recipient moves on at once, as before, and only a later message step waits.
A read before the step. src/domains/envoy/sequences/steps/linkedin_preread.py runs one read workflow as the sender and parks the step, processing -> pending with linkedin_run_id and scheduled_for, two minutes at a time, up to fifteen minutes. A run the day budget or a hold refused is tried again a day after it started, at most three times, with the attempt number in the run input and in the idempotency key <prefix>-<recipient step id>:<attempt>; the fourth refusal skips the step with "LinkedIn limit reached". Any other failure skips with the reason of the step.
An automated send reads the relation first. A connection request or a message step runs linkedin.profile.root before it drafts (step_linkedin_relation.py, prefix outreach-step-relation). A request to a first-degree person skips with "Already connected on LinkedIn", one to a person who holds an open request skips with "A connection request is already pending", and a message to anyone but a first-degree person skips with "Not connected on LinkedIn". A profile LinkedIn cannot show skips with "LinkedIn cannot show this person's profile". No model call and no approval is spent on any of them. The send tool reads the profile again at send time, so a change in between is still caught. A connection request step with without_note drafts nothing: the outbox shows an empty note draft as a bare request, and the approval sends it with no note. A noted draft is refused at the approval over 300 characters; the model is told to open with the name and a comma, write at most 250 characters and sign nothing, because the sender's name is already on the request.
An automated engage step comments on one post. The step runs linkedin.posts.root (prefix outreach-step-engage). Any failure skips with "LinkedIn posts could not be read". When the run succeeds, the code keeps the posts that have text, are not reposts and were not engaged with before: the earlier engagements are the LinkedIn communications of the person whose AI metadata names a post, waiting, sending, sent or replied. None skips with "No recent post to engage with". One model call, the AgentSpec in src/agents/envoy/linkedin_engage.py on the default production model, reads the posts as JSON data with the rep's comment guidance and answers the post id, the comment and one sentence of reasoning. The code refuses a post id that was not offered and cuts the comment at 1250 characters; a failed model call falls back to the manual task. The draft goes to the outbox with the post under communication_ai_metadata.research_data.linkedin_post and the reasoning under ai_reasoning, and the step clears linkedin_run_id. The approval reads the post id from that row and starts linkedin.comment.root with the text, or linkedin.reaction.root when the reviewer cleared the text, so one approval posts the comment or likes the post. A posted or reacted answer settles as sent, with the comment id or the engagement row id as external_id. A failed answer skips the step when its code names the post: resource_not_found with "The LinkedIn post is no longer available", and insufficient_permissions with "LinkedIn cannot show this person's profile". Any other code fails the step.
An automated profile view visits the profile. The step drafts nothing and asks no approval, because the visit sends no text (step_linkedin_visit.py, prefix outreach-step-visit). The first poll reads the sender's send window. A closed window parks the step until it opens, processing -> pending with scheduled_for, and starts no run. Otherwise the step starts one linkedin.visit.root run as the sender and polls it as a read: a running visit parks, a spent budget parks a day, a run the send window refused parks until the window opens and then starts the next attempt (6 attempts at most), and any other failed run skips with "LinkedIn cannot show this person's profile". A visited answer marks the step sent, processing -> sent, and advances the recipient. A visit_unconfirmed answer skips with "LinkedIn did not confirm the profile visit", and any other failed answer with "LinkedIn did not record the profile visit". Put the view before a connection request step, so the person sees the visit first.
A reviewer's skip names itself. A draft the reviewer declines with the skip option and no reason skips the step with "Skipped by the reviewer", and the skip publishes the step stats, so the step card and the recipients modal show it at once.
A reply ends the recipient, as an email reply does. The receiver marks the outbound message row replied, or writes replied_at on the invitation row of the note the person answered. The reply poller reads the sent LinkedIn messages and the sent invitation steps of active recipients of the last 30 days, asks the store which were answered by the organization and the ids alone, marks the communication replied, and calls the same handle_sequence_reply the Nylas webhook calls: the recipient completes and its drafts retire. The receiver's event is best effort, so the poll is the path, not a backstop.
A cancel on needs_reauth fails the run, and the poller fails the step with that message. The owner reconnects, and the rep re-queues the failed recipients of the step.
The LinkedIn tables
The four tables are in the agent schema. Every write is the service role.
agent.linkedin_invitations
id our row id, the correlation key
organization_id, connection_id, run_id
target_provider_id, target_public_identifier at least one
invitation_id the Unipile id; null until a send answer gives it
note_sent, adopted
status, accepted_via, failure_code
sent_at, accepted_at, replied_at
agent.linkedin_conversations
id, organization_id, connection_id
chat_id unique per connection
target_provider_id
agent.linkedin_messages
id, organization_id, connection_id, conversation_id
message_id unique per connection, the dedupe key; null on a failed send
direction out | in
status out only: sent | replied | failed
failure_code, text, sent_at, replied_at
agent.linkedin_webhook_events
id, organization_id null for a delivery no connection owns
received_at, source, event, account_id
delivery_id unique, the idempotency key
outcome applied | stored | dropped
raw the body as it arrived
Check constraints keep each row consistent. An accepted invitation has both accepted_at and accepted_via, and only a failed row has a failure_code. An outbound message has a status and an inbound message has none. Only a failed outbound message may lack a message id, a conversation and a send time.
A person reads the LinkedIn rows of their own connections. An organization admin reads every row of the organization. No member reads agent.linkedin_webhook_events.
Four of the five LinkedIn sends need an approval in every organization: an invitation, a message, a comment and a reaction. linkedin.visit_profile has no rule. Policy and governance owns those rules.
Provisional facts
These come from the Unipile documents or from an assumption, and no recorded call confirms them.
- Every Unipile v2 shape: the account, the profile and its
specifics, the relation request, the chat and message sends and theirmessage_id, which can be a list, and the message list. - The posts routes: the list of a user's posts, the comment on a post and the reaction to a post, their request bodies and their answers. No recorded call exists.
- Every Unipile v2 error type, and the LinkedIn refusal codes under the v2 scheme. The platform keeps the v1 codes as assumptions.
- The
message.new,relation.new,account.addandaccount.reconnectpayloads, and thestateecho. - The signature header and its format.
- Whether a plain profile read notifies the person. The v2 guide says the read alone reports no visit.
- The visit report.
specifics.notify_visit_tokenwas recorded on a live profile read on 2026-10-27. ThePOST /v2/{account_id}/users/visit-profileroute and itsProfileVisitedanswer come from the v2 reference, read on 2026-10-28. No live visit has confirmed that LinkedIn shows it, how long a token lives, or whether one token serves two visits. - The
relation.newdelay. v1 stated up to 8 hours. - The free-account rule,
is_premiumon the owner's own profile, and the cap of 5 notes. - The hold types: any 429,
provider/too_many_requestsandlimit_exceeded. - The account status values and their moves.
- The hosted link
configthat skips the product selection screen. - The 8 second attempt timeout of the LinkedIn tools, and the 3 second attempt timeout of the receiver.
Before the rollout gate is turned on
Do each step for the environment, before LINKEDIN_PROVIDER_ENABLED is set there.
- Make sure that every organization holds the four LinkedIn
require_approvalrules. Run the backfill statements of the two migrations again, because it is idempotent, or readagent.policies. Organization creation logs a failed rule write and continues, so an organization can lack them. - Set
API_BASE_URLto the public URL of the API andUNIPILE_API_KEY. A loopback or emptyAPI_BASE_URLmakes the link route refuse every link with a detail that names the setting, and the API warns once at startup. - Register the webhook endpoint with
scripts/register_unipile_webhooks.py --apply, then setUNIPILE_WEBHOOK_SECRETto the secret it prints. - Read the inbox scope above. The webhook delivers every conversation of the connected account. The receiver keeps only the chats the platform started, the chats of a person the platform invited, and the chats of a CRM person.
Idempotency and the replay journal
Every write and every send reads the journal, then claims an idempotency key. The claim is never taken without the read before it, and both steps use one key, derived once. See the journal is read before the decision.
scope = tool.<tool_name>
key = <run_id>:<step_path>:<args_hash>
step_path is the workflow node ID, or the literal agent. args_hash hashes the canonical arguments.
Do not key on the model tool call ID. An agent segment can restart after a crash, and the model mints a new tool call ID on every attempt. A key built from that ID never matches the earlier claim, so an approved send happens twice.
The claim record is the journal. agent.idempotency_keys already stores the status, the request hash and the response. A completed claim returns the stored result, which is exactly what a restarted segment needs. Do not add a second table beside it. See the note in what changes elsewhere.
Two identical write calls inside one run are one effect, on purpose. When a run must genuinely repeat one action, give the calls different arguments, or give the workflow two nodes, because the node ID separates them.
This rule has a sharp edge, so state it to a tool author. The journal cannot tell a replayed call from a deliberate repeat. Both look like the same arguments at the same step path. A replay must return the stored result, so a deliberate repeat receives it too, and the model reads a success for work that never happened a second time.
It is the right trade, because a duplicated send costs more than a skipped duplicate. It is only safe while tool authors know about it.
| Tool shape | Safe? |
|---|---|
crm.update(company=X, stage=qualified) | Yes. The second call is a no-op anyway |
email.send(to=X, subject=S, body=B) | Yes. Two identical mails is the failure we want to stop |
crm.append_note(company=X, text="called") | No. Two identical notes can be legitimate |
A tool whose repeat is meaningful cannot register in V1. repeatable = True is reserved for a later invoker that can carry a per-segment ordinal. This deployment refuses it rather than claim that it can separate a replay from a deliberate repeat.
read no journal key
repeatable = False key = <run_id>:<step_path>:<args_hash>
repeatable = True registration refused in V1
Registry validation cannot decide whether identical calls are one effect, so every write and send tool must declare repeatable = False. A read declares no value because it takes no claim. A tool such as crm.append_note stays unavailable until the runtime has a crash-safe repeat rule.
When a vendor supports idempotency, pass the same key downstream. That closes the crash after vendor success window.
Cost and spans
Every tool call opens one span. A metered call also writes one usage row through the canonical meter, and the span links it with usage_id.
This is not decoration. Policy accrual reads that meter, so a Signals Search run that spends money at Exa and FullEnrich must stop at its ceiling like a run that spends money on tokens. A vendor call that is metered nowhere is a hole in the budget.
Only a successful billable response writes a usage row. A policy or accrual refusal runs before the handler and writes none. An exhausted retryable failure also writes none. The vendor did not complete a billable operation that the handler can price.
An asynchronous provider job is the one exception, and it is not a second rule. The billable operation still writes exactly one row, and the row is still written from what the provider reported. Only the frame moves: the provider answers through a callback, so a durable function writes the row instead of the handler. See asynchronous provider jobs.
The handler calls a legacy vendor client with no usage logger. It then writes one UsageRecord through UsageMeter.record(). The row carries the ambient root_run_id, and the handler puts the returned row id in ToolResult.meta.usage_id. A meter failure raises. The handler never returns vendor data that the run cannot count.
The metered checkpoint already proves that the call has an ambient root run id before the handler starts. The handler reads the same id for the row. This order prevents a paid call that cannot name its run tree.
When a provider returns usage, use it. Firecrawl returns the credit count for a scrape, so the row multiplies the per-credit price by that count. A fixed one-credit row can undercount an enhanced proxy or a multi-page document.
Research tools
| Tool | Provider call | Public input | Result |
|---|---|---|---|
research.search_web | Exa Search | one bounded query, optional category and lookback | at most 10 pages with title, URL, date and highlights |
research.search_company | Parallel Search | one company name, optional website and one bounded research objective | at most 10 pages with title, URL, date and excerpts |
research.fetch_page | Firecrawl Scrape | one HTTP or HTTPS URL | the page URL, title and main-content Markdown |
research.discover_people | Exa People Search or Parallel Entity Search | one company, one bounded buyer persona, one provider and a limit | at most 10 projected candidates with provider provenance |
research.enrich_company | Hunter Company Enrichment | one exact company domain, and optionally the company name | both identity claims, their agreement state, and the canonical industry, location, employee, revenue and founding-year fields |
research.find_contact | Hunter Email Finder | one first and last name, and one company domain or one company name | the work address, its verification state, the provider status and confidence, both identity claims, a role-mailbox marker and the source count |
These tools are reads. They take no idempotency claim. research.search_company uses the low-latency Parallel Search endpoint that the current async client exposes. Parallel Task is the structured enrichment API, but it can run for hours. It is not an agent-loop tool. It is a submit tool and a collect tool over asynchronous provider jobs.
An empty Exa result is a successful billable response. It returns an empty list and writes one usage row. An exhausted 429, transport fault or provider timeout raises RetryableUpstreamError and writes no row. The strict research path must keep these outcomes distinct even though legacy Exa callers can still fail open.
The research.discover_people tool takes one provider because one tool span links to one usage row. The workflow fans out company and provider pairs when it needs wider coverage. One call never hides two vendor charges behind one usage_id.
The buyer persona can name a title, title family, seniority, function and location. The handler builds the vendor query from those bounded fields. It returns a common candidate projection and never returns the vendor payload.
research.find_contact answers one address the provider returned, and never one this platform built. A miss answers not_found, keeps the person and prices the usage row at zero. Hunter charges one credit for an answered address alone.
The answer carries one closed verification state: verified, unverifiable, accepts_all, unverified or not_found. The provider status outranks accept_all, because a domain that takes every address still tells the truth when it names one address invalid. A role mailbox such as info@ is flagged and kept, so the agent decides.
Two client behaviours make that rule necessary.
HUNTERIO_DRY_RUN makes the client answer synthetic data and reach no vendor. It builds first.last@domain for the email finder, and it builds the company name from the domain for the enrichment. The deploy passes dry_run=False, and both Hunter handlers also refuse a dry run themselves. One guard reads the client before the call, so a dry run spends nothing. One guard reads the _dry_run marker on the row before the meter, for a client that carries no attribute. The rule is absolute, so it holds at the handler and not at one call site.
HunterIOClient.find_email folds a 400, a 401, an unreadable body and an unexpected fault into the None a real miss returns. research.find_contact therefore passes strict=True, and a bad request raises instead of reading as a person with no email.
crm.search_people is a different tool. It reads people that already exist in the organization's CRM. It does not call a research vendor.
Every research declaration sets kind='vendor' and metered=True. Each handler sets meta.untrusted=True. The list tools set max_results=10. The common byte boundary applies after output validation.
Asynchronous provider jobs
Some providers finish work after the call that started it returns. FullEnrich is
the first. Registry validation refuses a timeout_s over STEP_BUDGET_S, which
is 120 seconds, so a job that runs for an hour cannot be one tool call. The
300 second bound on the field is the outer limit of the type and no tool
reaches it.
The platform answers this with four parts and one new table. A tool submits, a webhook stores the outcome, a durable function settles the cost, and a second tool reads the result.
tool fullenrich.submit claim -> reserve job -> one vendor call -> bind vendor id -> complete claim
(the run parks on the job, see the wait below)
POST /api/v1/agentic/webhooks/{provider}/{job_id}
verify -> read the reserved job -> bind vendor id -> store the transition
-> claim webhook.<provider> -> enqueue -> ACK
fn provider_job.settle write one usage row, once
tool fullenrich.collect read one tenant job, return running | completed | failed | cancelled
The record
agent.provider_jobs
id the platform job id; the correlation key
organization_id
root_run_id the tree the cost belongs to
run_id the run that submitted
tool_call_id the span of the submit call
provider 'fullenrich'
provider_job_id the vendor's own id; null before confirmation
contact_refs the ordered, unique refs in the submit request
state submitted | running | completed | failed | cancelled
result jsonb at most 32 KiB; larger results use provider_job.result refs
cost jsonb what the provider reported
usage_id the one usage row; null until settlement
submitted_at, updated_at, terminal_at
unique(provider, provider_job_id)
The correlation key is id and never provider_job_id. The platform id is
ours, it is stable, and it is minted before the vendor answers. A workflow
correlates on a value it can read from the submit step.
FullEnrich submit and collect
fullenrich.submit is a metered write. fullenrich.collect is an unmetered read.
Both names require explicit rights. Neither calls enrich_and_wait or polls.
The submit journal stores the small answer, including job_id; it stores no separate resource pair.
Submit accepts 1 to 25 contacts. Each contact needs a unique ref of 1 to 64 characters,
first and last names of 1 to 120 characters, and a company domain, company name or LinkedIn URL.
Company names have at most 200 characters. Domains are normalized. LinkedIn URLs have at most 512 characters.
The tool accepts no callback URL, organization, run ID, credential or arbitrary custom fields.
The provider receives work-email fields and custom.ref only. The deployment supplies the batch callback URL.
The platform derives job_id from organization, run, step path and the validated arguments hash.
It inserts the job with ordered contact refs before calling the provider.
The insert winner alone sends one POST. Replays and concurrent losers return the same reserved job.
A null provider_job_id means acceptance is unknown, not that the provider refused the job.
The submit response or a verified callback can bind this ID once. Neither can change an existing ID.
The batch name is agentic:<job_id>. Before callback binding, require this exact name in the signed body.
The signature alone does not bind the URL path. A callback for another reserved job must fail this name check.
There is no automatic POST retry. FullEnrich documentation does not establish support for Idempotency-Key.
A timeout, lost response or worker failure therefore leaves the reservation for callback recovery.
A replay must not submit it again, even after the tool journal expires.
A worker failure before the POST can leave an unsubmitted reservation. The workflow wait then times out.
This contract prevents duplicate batches; it does not promise a successful submission after every process failure.
A definite HTTP rejection raises the provider error and leaves the same reservation. It does not infer terminal provider cost.
Submit returns job_id. Collect accepts only job_id and filters by organization and provider in the database.
An unknown job and another tenant's job both return not_found.
Collect returns the job ID, mapped state, acceptance confirmation, and one compact result per requested ref.
A live job returns no contact results. A terminal job preserves partial success, including failed or cancelled batches.
A missing email does not make a completed batch fail.
Collect maps only exact echoed refs. It never uses position, name or domain as a substitute. Duplicate known refs produce no email for that ref. Missing refs and malformed rows produce explicit diagnostics. Unknown refs are counted and ignored. Diagnostics never copy arbitrary provider text. Each result carries the ref, email, provider status, confidence and a result status. Email and provider status have fixed length limits. Verification is derived only from an explicit provider grade. The regular tool boundary applies after projection. The full 25-contact projection must fit its 32 KiB limit without loss.
Large provider results
agent.provider_job_results owns the complete provider result as private data.
Each row has the job ID, result digest, organization and result. A composite foreign key prevents a tenant mismatch.
Rows cascade with their job. RLS is enabled; only the service role has table access.
The key is (job_id, sha256(canonical result)). Insert-on-conflict-do-nothing preserves each immutable body.
The conditional job transition selects one state, cost and result reference together.
A competing callback cannot pair its cost with another callback's body. Events use the stored terminal state.
The callback body has a 2 MiB limit. The shared bound() helper checks the 32 KiB job payload limit.
A result that fits stays inline. A larger result is stored before the terminal transition.
The job then stores {"kind": "provider_job.result", "id": "<job_id>/<result_digest>"}.
A failed result write prevents the transition and ACK, so a callback retry can recover.
Collect resolves only this kind, the same job ID and that digest, with an organization filter.
A missing referenced result is a storage fault, never an empty success.
Existing inline jobs remain readable. A job with no stored contact refs cannot invent a mapping.
The callback URL carries the job id
A vendor callback carries the vendor's job id and no organization. The claim is organization scoped, so the tenant must be known first.
The platform mints id before it calls the vendor, so it builds the callback
URL from it. FullEnrich takes a webhook_url for each batch. The route is
therefore per job, and the tenant comes from the path:
POST /api/v1/agentic/webhooks/{provider}/{job_id}
1. resolve the adapter from the path unknown provider -> 404
2. rate limit by client IP
3. verify the signature over the raw body bad signature -> 401
4. read agent.provider_jobs by id the tenant, from the reserved row
5. unknown job id -> drop reason `unknown_job`, then ACK
6. bind an absent vendor id, store a large result, then conditionally transition
7. a progress update ends here -> drop reason `not_terminal`, then ACK
8. IdempotencyService.claim(org, 'webhook.<provider>', delivery_id, sha256(body))
9. durable enqueue -> Inngest
10. ACK -> under 3 seconds
Step 6 runs before step 9. The durable truth is the row, and the event is a wake-up signal alone.
⚠️ Steps 8 and 9 run for a terminal update alone. A progress callback records what it says and stops. It takes no claim, so it cannot make the terminal delivery a hash conflict. The rule and its reason are under completion emits one versioned event.
⚠️ Nothing unverified reaches the database. The adapter comes from the path and the signing key is one platform-wide secret per provider, so verification needs no tenant. Reading the job first gives an unauthenticated caller one indexed read for each request, and it answers a different status for a job id that exists. That difference is an oracle over the id space.
The job id in the path is not a secret, and nothing here treats it as one. Step 3 gates step 4, so an attacker who learns a job id still changes no state without the signing key. The path answers which tenant, and the signature answers whether to believe the body.
FullEnrich posts once, on the terminal state. ENG-2300 captured one live
batch. The vendor sent no callback at CREATED and none at IN_PROGRESS. The
body carries id, name, status, cost and data, the status is uppercase,
and cost.credits counts hits and not rows. It signs with X-Signature-SHA1
over the raw body and carries no delivery id header, no timestamp and no retry
counter. FullEnrich also exposes a second callback URL for each contact
(webhook_events.contact_finished), and that URL does report progress. No tool
sets it.
A provider that takes no per-request callback URL reads the job by (provider, provider_job_id) instead. That pair is unique across every organization, so it
resolves one tenant. It is the fallback and not the shape to copy, because it
needs one cross-tenant read.
⚠️ Every provider signs with one platform-wide key in V1. verify() takes
the raw body and the headers, and nothing else, so it cannot check a secret held
per tenant. A provider that needs one is a change to this Protocol and not a
configuration choice, and no provider needs one today.
⚠️ An unknown job id is not an error. A retry for a job this platform already
deleted reaches no operator, and a 5xx would tell the vendor that a delivery it
made successfully failed. The handler counts unknown_job and answers its ACK,
exactly as a duplicate delivery does.
Duplicate and out-of-order callbacks settle to one transition. Step 6 is conditional on the legal from-states, so a terminal job stays terminal. This is the rule runtime execution applies to the Run row, applied to a second row.
Completion emits one versioned event
event type agentic.provider_job.completed.v1
Inngest name platform/agentic.provider_job.completed.v1
event id event:<provider delivery id>
data.data job_id the platform id, and the correlation key
provider, state, terminal
⚠️ The correlation field is job_id and never provider_job_id. The row
already has a column of the second name, and it holds the vendor's id. One name
for two values is the defect that reaches production as a wait that matches
nothing. The submit tool returns job_id, the event carries job_id, and a
match reads job_id.
⚠️ A terminal update emits, and a progress update does not. A progress
callback stores its transition and ends there. It takes no claim either. A wait
returns on the first matching event and reads no row again, so a running event
wakes a run on work the provider has not finished. The claim compounds it: the
key is the delivery id, so a progress delivery that claimed it makes the
terminal delivery a hash conflict, and the run then parks for its whole timeout
with the result already stored. ENG-2300 measured this.
The version is in the event type. See triggers for the rule that governs it.
The Inngest event id gives short-term deduplication over a 24-hour window. It is
not the durable guard. The webhook.<provider> claim and the conditional
transition are.
The run parks on the job, and two reads close the gap
A wait node whose correlation names a provider job reads that row once before
it registers the wait, and once again on the timeout. Those two reads are the
mechanism the approval wait already uses. See
the wait node.
⚠️ Without the first read the run parks for the whole timeout. The submit step commits, the callback lands, and the wait registers after the event is gone. The job row survives that gap, and the read finds it terminal.
ENG-2300 measured one live FullEnrich batch. The submit call answered in 0.83 seconds and the one callback arrived 307 seconds later, so the gap is wide for a batch that does real work. A batch that ends at once closes it: a drained account reports its ending immediately, so the submit tool reserves the row before it sends the request.
A parallel node holds at most one parking subtree, so it holds at most one
provider-job wait. RunManager.resume() counts pending approval rows and does
not count provider jobs. Lifting that rule is not V1 work.
The cost settles once, off the run frame
provider_job.settle is an Inngest function on the completion event. It writes
one UsageRecord through UsageMeter.record(), stamped with the job's
root_run_id, and then stamps the row:
UPDATE agent.provider_jobs SET usage_id = :usage_id
WHERE id = :job_id AND usage_id IS NULL
The meter uses a deterministic usage ID derived from the platform job ID. Its existing upsert prevents a second charge if the process fails before step acknowledgement. Zero rows from the stamp means another attempt settled it. Cancellation, timeout, a replayed callback and a repeated collect all read the same stamped row and change no charge.
Settlement is not on a run frame, and this is deliberate. A cancelled run and
a timed-out wait both end before the vendor answers. The money is still spent, so
the row still names agent_root_run_id and the tree total grows after the Run is
terminal. A total that hides it understates a tenant's bill.
⚠️ This is the one metered path that does not end a Run on a meter failure.Observability
states that UsageMeter.record() raises and the caller ends the Run. There is no
Run to end here, and the Run that submitted is often already terminal. So the
function raises, Inngest retries it, and an exhausted retry reports to Sentry and
leaves usage_id null. A null usage_id on a terminal job is the query that
finds an unsettled charge.
provider.collect writes no usage row. It reads the job row. A read takes no
claim, so repeating it is free.
row state collect answers
submitted, running running
completed completed
failed failed
cancelled cancelled
submitted and running collapse, because a caller does nothing differently
between them: the work is not finished either way. cancelled does not collapse
into failed. The provider stopped the job, and a workflow that retries a
failure must not retry a job somebody cancelled.
FullEnrich design stress cases
ENG-2210 owns these cases.
| Case | Required outcome |
|---|---|
| Empty batch; 26 contacts; duplicate or blank ref | Refuse before any provider or storage write |
| Missing identity; invalid domain or LinkedIn URL; unknown input field | Refuse the input |
| Ordinary submit replay; concurrent submit; expired journal | Same job; at most one provider POST |
| Worker stops before POST | Reservation remains; no automatic resubmit; wait can time out |
| Provider accepts; response is lost | Callback binds the reserved job; replay makes no POST |
| Provider rejects; credential absent | Surface the fault; do not invent provider completion or cost |
| Callback precedes submit response | Bind once; preserve terminal state when the response arrives |
| Callback carries another vendor ID or another reserved job name | Refuse the mismatch; change no terminal data |
| Bad signature; unknown provider; unknown job | Reject signature/provider or ACK unknown job, with no mutation |
| Progress before terminal; duplicate terminal; conflicting terminal | Progress emits nothing; first terminal result stays fixed |
| Result-store write fails; transition fails; enqueue fails | Retry recovers each boundary; no result loss or duplicate charge |
| 32 KiB boundary; large Unicode result | Inline when it fits; otherwise preserve the full result behind a ref |
| Callback exceeds 2 MiB | Refuse the body before parsing or storing it |
| Forged ref; wrong tenant; wrong provider; missing result row | Refuse access or raise storage fault; never return another job's data |
| Submitted; running; completed; failed; cancelled | Return the documented collect state without provider calls |
| Reordered contacts; missing rows | Exact-ref mapping; explicit missing-ref result |
| Duplicate known ref; unknown ref; malformed row | No guessed mapping; bounded diagnostics; unaffected contacts survive |
| No email; invalid email; unknown grade; catch-all | Preserve truthful absence or grade; never invent verification |
| Large provider profile; long status; long email | Compact bounded projection; no raw profile in the tool result |
| Callback before wait; inside registration gap; after timeout | Durable read finds completion; gap can wait until timeout recheck |
| Run cancelled; late callback; repeated collection | No resumed cancelled lane; cost can settle once after the run ends |
| Meter commit before step acknowledgement; parallel settlement | Stable usage ID; one durable usage row |
| Role lacks submit or collect; exhausted budget | Policy refuses before the provider effect |
| 25 maximum-size contact results | Complete projection fits 32 KiB; no silent contact loss |
The provider adapter
One Protocol and one implementation per provider. The deployment maps provider names to adapters.
class ProviderWebhookAdapter(Protocol):
provider: str
def verify(self, raw_body: bytes, headers: Mapping[str, str]) -> bool: ...
def delivery_id(self, payload: dict) -> str: ...
def read(self, payload: dict) -> ProviderJobUpdate: ...
ProviderJobUpdate carries the vendor job id, the next state and the reported
cost. The vendor job id is the vendor's own, and the route compares it against
the row the path named. The adapter verifies authenticity and maps the vendor's vocabulary. It
touches no Run, no meter and no span.
verify() runs before delivery_id() and read(), and the route holds that
order. The path names the job, so no method reads an unverified body to find
the tenant. read() answers trusted fields alone, and it runs after the
signature passes.
The vendor job id read() returns must equal the one on the row. A signed
delivery for job A that names job B in its body is a provider defect or a replay
across jobs. The handler counts job_mismatch and answers its ACK.
The platform refuses a registry of adapters for the same reason it refuses a
general plugin framework. One dict keyed on the provider name resolves the
route parameter, and a route parameter that names no adapter answers 404.
provider_job.settle retries three times, which is the count every metered path
uses. An exhausted retry reports to Sentry one time and leaves usage_id null.
Retention, and what a vendor still owes
agent.provider_jobs is deleted with its Run. The composite run key cascades, so
the 13 month agent.runs window is this table's window and retention.sweeper
needs no fourth sweep. A job outlives the 90 day span window on purpose: the span
that submitted it is gone long before the row is.
Pick timeout_s from the provider's own stated completion time, not from the
30 day ceiling. A wait that outlives the Run's max_run_duration_s is clamped
to the Run deadline, so a generous timeout does not park a Run for ever. A
timeout under the provider's real latency does: the wait ends, the Run moves on,
and the callback settles a cost for work the Run never read.
A vendor with no callback needs a poll instead. V1 builds no poll backstop: the
wait timeout ends the Run, and a job whose callback never arrives keeps state
at its last transition. A provider that drops deliveries needs a reconcile sweep
before it ships, and it is not built before a provider forces it.
Cancellation
A tool call is a safe boundary, so the executor reads agent.run_control before it and after it. The invoker never interrupts a call in flight. If the effect is already sent, let it settle, record it, then stop.
Registry validation
build_registry runs when the process builds its object graph, and the MCP resync will run the same checks when a connection syncs. It does not run per call.
⚠️ It does not fail a deploy. The graph builds on the first function run and on the first request, so a broken declaration fails those two. Every check below therefore raises RegistryError, whose message a person reads.
It checks:
- the name is unique, valid and not a platform collision;
- the derived model facing name is unique, and at most 64 characters;
- the input and output schemas are valid JSON Schema;
- the handler or the connection binding resolves;
approval_preview=Trueresolves to a handler with a callablepreview();side_effectsis known, and asendtool declares a connection;- a
budget_classneeds aconnection, names a known class, and starts with that provider before the dot; secondary_budget_classesneeds abudget_class, names no class two times, and each class follows thebudget_classrule;- a tool with a durable binding declares neither a
connectionnor a budget class; - every
writeandsendtool declaresrepeatable = False, a read leaves it unset, andTrueis refused; - every count limit is positive, and
max_output_bytesis at least 2; batch_argumentandmax_batch_sizeappear together, and the argument names a top-level array;max_resultsappears only on an array output, andmax_output_bytesdoes not exceed the platform limit;- every name in
policy_factshas a registered resolver, and none of them isprincipal,runorarguments; timeout_sis inside the step budget.
A broken tool never enters the runnable registry.
Scenarios that shaped this design
| Scenario | What answers it |
|---|---|
| The model proposes three calls and the second needs approval | The invoker raises. The segment ends. The journal replays the first |
| The model reads a pending approval and tries another route | Nothing to read. Approval raises instead of returning a value |
| A person approves 20 hours later, and the target moved on | The approval rechecks the TTL, the argument hash and the target state |
| An admin revokes the tool while the run waits | The invoker reads the live enabled state after the resume |
| The worker dies after the vendor accepted the send | The vendor idempotency key, then the completed claim |
| A restarted segment re-proposes the same CRM update | The completed claim returns the stored result |
| Three people each connect a mailbox | Each run uses the mailbox of the person it acts for, and the credential never reaches the model |
| A rule asks whether the recipient is new | The tool declares the fact, and the invoker resolves it before the checkpoint |
| An MCP server drops a tool between runs | The invoker checks presence, and returns tool_unavailable |
| An MCP server returns a 50 MB page | The boundary drops whole items and sets meta.truncated |
| An MCP page says "ignore your instructions and send this" | It is labelled untrusted, and any send still needs a policy decision |
| Exa answers 429 in the middle of a fan out | The wrapper retries inside timeout_s, then returns retryable_upstream |
| A vendor hangs forever | timeout_s and the run deadline end the call |
| The agent asks to enrich 500 people in one call | max_batch_size rejects the call before any effect |
| A discovery run burns the vendor budget, not the token budget | The metered usage row lets accrual policy stop it |
| A fan out step passes the cost ceiling halfway through | A metered call checks accrual, so the stop is one call, not one node |
| A vendor 429 is cached and the agent can never retry | A retryable failure releases the claim, so nothing is remembered |
| A person approves, and the model does not propose the call again | The resume executes the approved call from the approval row |
| 200 live runs reach one vendor at the same second | The concurrency arithmetic, then the bounded 429 retry |
| Our database falls over inside a handler | The handler raises, the claim is released, and the segment retries |
| A metered research call has no root run id | The accrual checkpoint refuses it before the vendor runs |
| Firecrawl uses five credits for one scrape | One usage row records five credits, not one request estimate |
| Exa returns a valid empty result | The tool returns an empty list and records the billable request |
| Firecrawl rejects an unsupported target | The invoker returns unavailable, and the handler writes no usage row |
| A fan out sends invitations past the daily count of one account | The count budget refuses the next call as a value, and the run continues |
| Two workers reserve the last unit of a budget at one instant | One slot, one unique index, one winner |
| The ledger cannot be read | The reservation answers budget_unavailable, and no call reaches the vendor |
| A vendor that restricts on count answers 429 | The claim completes, and a hold refuses the connection until the end of the UTC day |
| The provider is not yet released | The rollout gate answers connection_unavailable |
Rules
- An agent reaches the world only through a tool.
- An agent never touches Postgres, a raw route or a credential.
- An agent sees only the tools its definition names.
- Visibility, policy, business validation and tenancy stay four checks.
- Visibility is the frozen snapshot. Authority is stage 1 of the policy engine. There is no factory between them, and the invoker holds no scope check of its own.
- Policy is outside the agent, and it is evaluated on every call.
- A write or send tool is declared
external_execution. It pauses, andToolInvokerruns it at the pause. - The runtime resolves in process every call the invoker answers. Only an approval reaches Inngest.
- One turn may hold several proposals, and each gets its own decision. A denial returns as a value.
- A read tool runs inside the loop, so the adapter catches every exception it raises and classifies it. It re-raises
StopAgentRun, which is the stop it raised itself. SegmentStopcarriesceiling | cancelled | failed, it belongs to the segment, and the first writer wins.ToolResult.stopis written by the invoker alone._bounddrops a value a handler wrote, and no caller renders it to a model.- A metered call refused at the run cost ceiling ends the run partial in all three callers. A fault ends the run failed under the result's own code.
- The drain loop is the third caller. A write or send tool never reaches the adapter body.
- The approved outcome fills the paused call's own empty result slot. No message is edited.
- The resume executes the approved call. It never waits for the model to propose it again.
- An
authorized_bythe approval row does not cover is ignored. The call is decided live, never denied. - A covering row that reads
rejectedanswersrejected, and so does a claim a person already refused. Neither files a second proposal. ApprovalRequiredcloses thetoolspanok. A pause is not a failure.- Every write and send claims an idempotency key, and the claim is the journal.
- The journal is read before the policy checkpoint, and the claim is taken after it.
- A live
processingclaim on the call's own key raises. It is never aToolResult. ToolInvokeropens thetoolspan before the checkpoints, so a refused call is in the tree.- Span identity is ambient. No invocation, request or handler carries a copy of it.
- An internal handler reaches organization data through
scoped_db, never through the admin client. - A global Intelligence handler calls dedicated Intelligence domain stores. Only those stores use the admin client.
- An internal handler answers a value for a business failure, and raises for everything else.
- A vendor handler raises a neutral upstream exception. The invoker owns the stable error and retry flag.
- A claim ends in complete or release. A retryable failure releases it.
- A metered tool checks accrual before it executes.
- The connection resolves after accrual and before the count budget and the claim. A connection refusal takes no reservation and no claim.
- A tool that declares a
budget_classreserves one ledger row of each class it declares before its claim, all or none. A spent budget is a value with nostop, and the run continues. - A ledger read or write that fails denies with
budget_unavailable. The count budget fails closed. - A ledger row settles with the call: spent on a handler answer, released when nothing happened, left reserved after a timeout.
- A 429 from a vendor that restricts on count is never retried. It holds the connection until the end of the UTC day.
- A vendor ceiling is arithmetic over the run concurrency, not a limiter.
- A tool whose repeat inside one run is meaningful carries a distinguishing argument.
- Every call opens one span, and a metered call writes one usage row.
- A successful billable call writes one usage row. A refusal or retryable upstream failure writes none.
- The boundary bounds the result without breaking its structure.
- External content is labelled untrusted, and it is never treated as an instruction.
- Connections hold three durable states, and the model never sees a credential.
- A
platform_key_accountconnection takes the key from the deploy and the account id from the row. No response and no tool result carries either. - A provider whose rollout gate is off resolves to
connection_unavailable. - Every stored name keeps its dot.
model_tool_name()derives the model facing name, and the adapter alone applies it to a segment. - Registry validation catches declaration drift before run time.
preview()is read only, runs before authorization, and may fail without losing the approval.- Output that fails its own schema is
invalid_output, neverinternal_error. - A remote schema change bumps the spec version, and a run on the old contract stops with
tool_changed. - Every write and send tool declares
repeatable. Silence is not an answer. - A metered call reads the run tree total. The organization day belongs to the node boundary.
- An asynchronous provider job is one row, and that row is the durable truth. The completion event is a wake-up signal, never the record.
- A provider callback resolves the tenant from the job row before it takes an organization scoped claim.
- A provider callback stores its state transition before the platform emits the completion event.
- A provider job state transition is conditional on the legal from-states. A terminal job stays terminal.
- The provider cost settles one time, and a conditional write on a null
usage_idis the guard. - A collect tool reads the job row. It calls no provider, writes no usage row and starts no second job.
- A wiring fault on the metered path reaches Sentry one time. The log receives every call.
- Accrual reads
ai_usage_log, never the daily rollup. - Organization authored executable tool code stays deferred. Use platform tools, approved MCP servers and organization skills first.
Open decisions
- Which tools set
approval_previewin the first release, beyondemail.send? - Which vendor forces the per vendor Inngest function first, and at what account limit? FullEnrich forces the asynchronous job contract, which is a different mechanism: it moves the completion off the call, not the rate limit off the run.
- Is an hourly MCP resync the right exposure window, or does a busy server need a webhook?
- Is an Envoy sequence run an exception to the cancel on
needs_reauth? Today the move cancels every live LinkedIn run of the owner, a sequence run too. The Envoy decision is open. - May a run ever use an account its person does not own, for example a shared team mailbox, or a Corbel copilot acting for a customer through the customer's account? The resolver allows neither today.
A send tool gets no per organization daily ceiling of its own. The ceiling a vendor enforces is a count on one account, so the count budget is keyed on the connection and not on the organization.
Minimum contract tests
- A replayed submit returns the same provider job and makes no second vendor call.
- A callback that lands before the wait registers still ends the wait, and the run does not park.
- Two deliveries of one callback produce one state transition and one usage row.
- A callback that reports an earlier state than the stored one changes nothing.
- A callback for an unknown provider job id counts a drop reason and answers an ACK.
- A callback whose signature fails changes no state.
- The cost settles once when the run was cancelled before the callback arrived.
- Repeated collection answers the same result and writes no second usage row.
- The Agno adapter cannot bypass a policy denial.
- A database fault inside a tool ends the segment, and the model claims nothing.
- Two segments in one process do not read each other's stop signal.
- A call policy allows pauses, is granted in process, and never reaches Inngest.
- A write tool's callable is never executed by Agno; every effect goes through the invoker.
- Two proposals in one turn get two decisions, and a denial does not stop the call beside it.
- A required approval ends the segment, and the resume executes the tool once into the paused call's slot.
- A required approval closes its
toolspanok, carrying the approval id. - A resumed pause holding a second write call decides it live, and parks the run on its own approval.
- An oversized batch produces zero side effects.
- An oversized batch reaches no journal read, policy read, claim, handler, preview or meter.
- Batch argument and batch size must appear together, and the argument must name a top-level array.
- Result count and byte limits compose, and either limit sets
meta.truncated. - A one-byte output limit does not build.
- A preview is one non-empty line of at most 512 UTF-8 bytes.
- A preview timeout or invalid result files the approval with
preview=None. - A crash after a vendor effect does not repeat the effect.
- A turn holding two gated write calls finishes in three passes, with two effects.
read()writes no row, so a call that stops for a person leaves no claim behind.- A crashed worker's claim is read as
absent, and the next attempt reclaims and executes it. - A live claim on the call's own key raises, and the model reads no result for it.
- A restarted segment returns completed journal results. Interrupted and new calls follow the claim, lease, and vendor recovery rules.
- An expired, revoked or ambiguous connection maps to a stable error code.
- A revoked connection answers
connection_revoked, and the call takes no reservation and no claim. - A provider whose rollout gate is off answers
connection_unavailable, and no handler runs. - The resolver never returns the platform key to a handler result, a span payload or a log line.
- A
budget_classwith noconnection, an unknown class, or a class whose prefix is not the provider does not register. - A tool with a durable binding that declares a
connectionor abudget_classdoes not register. - A write or send replay returns from the journal read before the reservation, and writes no ledger row.
- A replay that passes the journal read and finds the claim completed releases its row, so it holds no slot.
- A retryable failure releases its ledger row, and the next call reserves.
- A count refusal returns
budget_exhaustedwithstopNone, and the run continues. - A 429 from a vendor that restricts on count writes one hold, completes the claim, and the next call on that connection refuses before the handler.
- A call that declares a secondary budget class holds one row of each class, or none. A refusal on the second class releases the first row.
meta.no_effectreleases the main class row and spends each secondary row. The claim still completes.- A secondary budget class with no
budget_class, or one class declared two times, does not register. - A send with no answer raises
ConflictError. The claim completes, the rows spend, and a replay sends nothing. - The Unipile client repeats a GET one time, and never repeats a POST.
- A connection request to a first-degree person answers
already_connectedand sends nothing. - A message to a person who is not first-degree answers
not_connectedand sends nothing. - A free or unknown account at the note cap sends the invitation with no note, and stores
note_sent = false. - A sign-in webhook with a wrong token, a used link or an expired link binds nothing, and the callback never binds.
- A reconnect webhook for an account that is not the link's connection binds nothing.
- A Unipile webhook with a wrong signature stores nothing, and a repeated delivery changes nothing.
- A Unipile webhook for an account no live connection holds is stored as
dropped, and changes no row. - A revoked tool is refused after an approval resume.
- A handler result is valid against
output_schemabefore the boundary changes its transport form. - One item larger than the whole limit keeps its shape, with its big fields replaced by a
ResourceRef. - Redaction runs before bounding, so a credential in a dropped item is still removed.
- An external secret never appears in a
ToolResultor a span payload. - A metered vendor call reaches the usage meter and the accrual checkpoint.
- A metered call is refused when the run is already over its cost ceiling.
- A metered call with no root run id reaches no handler and writes no usage row.
- One successful vendor response writes one row, and its id reaches the tool span.
- A retryable upstream failure writes no usage row.
- A vendor handler constructs no
ToolError; the invoker maps its neutral exception. - A valid empty Exa response is distinct from an exhausted retryable failure.
- An unsupported Firecrawl target maps to
unavailableand writes no usage row. - A provider credential fault or a meter fault raises and returns no vendor data.
- Vendor arithmetic adds two wide nodes that can run in parallel against one account.
- The full vendor retry budget fits inside the declared tool timeout.
- A Firecrawl row uses the response credit count when it is available.
- Many metered calls that meet the same wiring fault produce one Sentry report, and one log line each.
- A retryable failure leaves no claim, and the next identical call executes.
- A business failure leaves a completed claim, and the next identical call returns it.
- An approved call executes on resume even when the model proposes nothing.
- A rejected approval returns a
rejectedresult into the loop, and the run continues. - Schema and handler drift fails validation before publication.
- A handler returning off schema data produces
invalid_output. - A failing
preview()still creates the approval, with the generic render. - A preview performs no write, no send and no metered call.
- A remote input schema change makes the next call return
tool_changed, notinvalid_input. - A tool that declares
repeatable = Truecannot register in V1. - A non
repeatabletool called twice with identical arguments writes one. - A
sendtool cannot register withrepeatable = True. - A run principal carries the tool names its definition names, and the invoker refuses a name it does not hold.
- A
Droppedmarker becomes aResourceRefonly when the spec names anitem_kindand the item carries anid. - A domain business exception is a value, and every other exception raises and ends the segment.
- A spec whose remote schema changed carries a new
schema_hashand a higherversion. - A dotted platform name reaches the model derived, and a paused call maps back to the dotted name.
- Two contracts that derive one model facing name are refused before the model sees either.
- A derived name over 64 characters is refused at registration.
- A tool result never carries
parent_cancelled.