Agents
An agent is a specification, and the specification is a row. Its prompt, its
model, the external tools it may call and the agents it may delegate to are all
data, so adding one is an insert and changing one is an UPDATE — nothing is
redeployed, and no workflow that uses it is touched.
The three moves below are the whole loop: write the spec, save it, call it over REST. Everything after them is what the row can carry once you want more from it — including the prose it shares with other agents, which is skills rather than more of this page.
What the model receives on each turn
We describe what the runtime sends a model with the five components of context engineering, the way Anthropic's write-up frames them. The aim there is to find "the smallest set of high-signal tokens that maximize the likelihood of your desired outcome", and in Percolate each of the five is read from rows, so you can query what an agent had in front of it when it answered.
| Component | Where it comes from | Covered in |
|---|---|---|
| System instructions | The agent's description, stored as system_prompt, followed by a <context> block holding the time, locale and tenant from the request headers |
write the spec, skills |
| Conversation state and history | The conversation's recent messages, with a summary of older ones at the oldest end once one has been written | the rest of what the row carries |
| Retrieved knowledge | Query results from the world model, fetched by a tool call during the turn | citations, querying |
| Tool definitions and outputs | The tool servers the agent binds, narrowed by its allowlist, and what those calls return | tools, delegation |
| Memory systems | What earlier sessions wrote back into the world model, found by the same retrieval as knowledge | memory from a conversation |
Retrieval is not a step the runtime runs before calling the model. The agent
asks for it through a tool, the result sits in the context for that turn, and
afterwards only the citation is kept, so the next turn fetches again against the
current index. Memory lives in the same tables as knowledge, and the difference
between the two is where a row came from: a source you ingested, or a
conversation reflect_session read back into the graph, each fact citing the
message that said it.
The system instructions are the agent's system_prompt, then its pinned
skills as a <skills> block, read as the person talking to the
agent so a skill they cannot read is not composed in, then the context block.
Skills an agent would match or fetch during a turn are not built. One gap
remains in what percolate-core 0.2.17 assembles: the summary slot is read on
every turn, but the summarize_session_window workflow that writes it is not
installed by a stock install, so a long conversation is windowed without a
summary of what fell out. The summary is not memory: it belongs to one
conversation and is never carried into another. Memory from a conversation is
reflect_session, which you start yourself (ingest).
Write the spec
An agent definition is a JSON Schema document. There is no prompt key: the
agent's system instructions are the schema's description, and the structured
output is its properties.
Here we write an agent as a file we can commit, review in a diff, and hand to anything that reads JSON Schema.
type: object
name: harbourmaster
description: |-
You answer questions about the fleet.
Start with SCHEMA to see what exists, then query. Never guess a vessel name.
model: anthropic:claude-sonnet-5
tools:
- server: harbour-query
tools: [query]
properties:
verdict: {type: string}
vessel: {type: string}
required: [verdict, vessel]
A description this short earns its length. Anything in it that would be
equally
true of a sibling agent belongs in a skill — a fragment stored
once and referenced by every agent that needs it — rather than copied into each
prompt and left to drift.
The same document is what a Python class produces, because the convention was chosen so that the two forms are one artifact rather than two:
class Harbourmaster(BaseModel):
"""You answer questions about the fleet.
Start with SCHEMA to see what exists, then query. Never guess a vessel name.
"""
model_config = ConfigDict(json_schema_extra={
"name": "harbourmaster",
"model": "anthropic:claude-sonnet-5",
"tools": [{"server": "harbour-query", "tools": ["query"]}],
})
verdict: str
vessel: str
type: object
name: harbourmaster
description: |-
You answer questions about the fleet.
Start with SCHEMA to see what exists, then query. Never guess a vessel name.
model: anthropic:claude-sonnet-5
tools:
- server: harbour-query
tools:
- query
properties:
verdict:
title: Verdict
type: string
vessel:
title: Vessel
type: string
required:
- verdict
- vessel
Why it works — there is no export step, because the authored form and the deployed form are the same document
The build step is model_json_schema(), which pydantic already wrote. That is
what turns "authored in code, deployed as data" from a pipeline into an
identity: the class has full IDE support and is unit-testable in isolation, and
the moment it is finished it is already serializable, because the only
constraint on an agent definition is that it holds no inline tool code — only
references to external tools, which the next two sections cover.
The convention comes from p8k8, the system this one replaces, and it is kept
for what it rules out. A prompt: key makes the prompt a string in
a config file. description makes it the docstring of a class, which means the
description a human writes is the description the model receives, and every tool
that already reads JSON Schema reads an agent for free.
One thing was dropped from that lineage rather than inherited without comment.
p8k8
distinguished structured_output: true — properties are the output contract —
from false, where properties are "thinking aides" that shape the model's
reasoning without ever being returned. Here properties has exactly one
meaning, the output contract, because that is what makes chained_action
reachable: an automatic follow-on action needs a validated shape to fire on.
Thinking aides are a real idea and simply are not in this system.
Save it
Applying an agent is a migration, not a deploy.
Here we get that file into the database, from psql or from anything that can
make an HTTP request.
There is one function underneath this, so a migration, a script or PostgREST all reach it the same way:
select agentic.upsert_agent($j${
"name": "harbourmaster",
"model": "openai:gpt-4o-mini",
"category": "tool_user",
"system_prompt": "You answer questions about the fleet."
}$j$::jsonb);
This writes over the agent the sample loaded, because harbourmaster is the
name it ships — an upsert, so the fields you leave out (its skills, its output
schema) survive and the ones you name are replaced. That is what the example
shows of the
example and it is worth checking before you run it against a database you just
loaded.
Two things are absent from it. The model matches the sample's, which is
openai because the sample already needs an OpenAI key to embed its corpus and
a second provider is a second key to obtain. And there is no tools array, so
the binding the sample made survives: harbourmaster stays bound to
harbour-query, the query tool server the compose stack runs as query-mcp.
Tools and MCP below registers that
server and binds it, which is the order that works.
curl -s http://localhost:3000/rpc/upsert_agent \
-H 'Accept-Profile: agentic' -H 'Content-Profile: agentic' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"p_spec": {"name": "harbourmaster", "model": "anthropic:claude-sonnet-5"}}'
Or push the file you wrote at the top of this page, unchanged:
export P8_DSN=postgres://p8:p8@localhost:5432/percolate # p8:p8 unless .env changed them
export P8_USER_ID=$(psql "$P8_DSN" -Atc "select id from rbac.users where email = 'me@example.com'")
percolate agent push harbourmaster.yaml
That is the only one of the three that takes the schema document — the form
with description and properties. The function and the REST call both take
the row, where those two are system_prompt and structured_output_schema, and
they ignore what they do not recognise: push the documented form at
upsert_agent directly and you get a 200 and an agent with an empty prompt.
P8_USER_ID is not optional and there is no privileged fallback. The upsert is
gated inside the database on the caller's permission, so the CLI has to connect
as somebody; without it you get not authorized to author agents, which is
the check working rather than a misconfiguration.
percolate auth bootstrap prints the id it creates. P8_DSN is the variable
the agent commands read — not the P8_ADMIN_DSN the sample loader uses —
and without it they try a development database on port 5500 and fail with
Connect call failed ('127.0.0.1', 5500).
Why it works — an omitted key means "leave it alone", and getting that wrong is a silent total loss
upsert_agent takes the row shape — system_prompt and
structured_output_schema spelled out — while the file you commit is the schema
document above, where those two are description and properties. percolate
agent push is the one that makes that translation, which is why it is the only
one of the three above that takes the schema document; the function and the REST
call both take the row and will accept the document happily, ignoring what they
do not recognise, and leave you an agent with an empty prompt.
The upsert updates only the columns whose keys are present, never every
column. Written the obvious way — set x = excluded.x for each one — the most
ordinary edit there is, {"name": "harbourmaster", "model": "…"} to change a
model, returns 200 and leaves the agent with an empty system prompt and no
tools: it still exists, still resolves by name, and can do nothing. excluded
carries the column default for every key the caller did not send. This was
found by doing it, and the same mistake had already shipped once one function
away in upsert_tool_server, which is why the rule is now stated once and
applied to every column.
Authorization is inside the function rather than in the client. The upsert is
gated on rbac.has_permission(uid, 'agents', 'update'), so pushing as a user
without it fails in Postgres — a check the client performs is a suggestion, one
the database performs is a rule.
Nothing restarts. Agents are loaded at invocation, never at startup, which is the property that makes "adding an agent is an insert" actually true rather than true-after-a-rollout.
Every change is kept
An agent's earlier versions are rows you can select.
Change the prompt with an ordinary UPDATE and ask the agent again: the
runtime reads the row on every turn, so the next turn uses the new prompt with
nothing restarted. What the UPDATE replaced is not lost:
update agentic.agents
set system_prompt = 'You answer questions about the fleet, in one sentence.'
where name = 'harbourmaster';
select version, system_prompt, held_from, replaced_at, replaced_by, replaced_by_role
from agentic.agent_versions_api
where name = 'harbourmaster'
order by version;
Each row is a version that was replaced: the whole row as it was (in agent),
when it was written, when it was replaced and who replaced it. replaced_by is
the signed-in user, and is empty for an operator at a psql prompt;
replaced_by_role is the database login either way. upsert_agent,
percolate agent push and a plain UPDATE are recorded the same way, and a
change that changes nothing writes no version. The current prompt is in
agents_api, not here.
Why it works — a trigger, and the agent's own read rule
An AFTER UPDATE trigger on agentic.agents writes the row it replaced into
agentic.agent_versions. It runs as the table's owner, so the version is
written whoever made the change, and nobody can write one by hand: the table
has no insert grant. A version is readable exactly when its agent is: the
policy asks agentic.agents, so a private agent's history is its owner's.
The runtime reading the row per turn is what makes "no redeploy" true: measured
by sending turns to a stub model, a turn sent the old prompt, an UPDATE followed, and
the next turn on the same process sent the new one.
Call it over REST
The runtime has two HTTP endpoints. One runs a turn and streams it; the other relays a stream to somebody who did not start it.
Here we talk to the agent we just saved, with an ordinary HTTP client and no bespoke fields.
Two things have to be true before this returns anything but an error, and neither is about the agent:
$TOKENis a JWT you sign, and a fresh install has nobody to sign one for. Install § the first user, and a token is the whole of it; without it this endpoint answers401 a verified bearer token is requiredbefore the stream opens.- The runtime needs the key for the model the row names. The published
image carries every provider pydantic-ai drives, and each reads its own
variable from the runtime's environment —
OPENAI_API_KEYforopenai:,ANTHROPIC_API_KEYforanthropic:— set in the compose.envbefore the stack starts.LLM_API_KEYis the worker's credential mechanism and is not read here. A row naming a provider whose key is missing answers 200 and fails inside the stream, as aRUN_ERRORevent. The sample already needs an OpenAI key, so the statement below points the row at an OpenAI model;OPENAI_BASE_URLaims the same client at any OpenAI-shaped gateway. Behind a gateway, name the modelopenai-chat:<model>: plainopenai:calls OpenAI's Responses API,/v1/responses, which most gateways do not serve, and the turn fails with a 404. A runtime with REM-973 sendsopenai:to/chat/completionson its own whenever the base URL is not OpenAI's, soopenai-chat:is right on every version. - A key the provider refuses is final, and says which key. Asked for the
whole answer (
"stream": false, which is how anagent:workflow step calls it), the runtime answers with the provider's own status rather than a 502:401or403for a refused key, with a detail naming the model and the variable it is read from, and429with codeinsufficient_quotafor an account with no credit. A workflow step reads either as final and fails once, where it used to retry five times on "the provider failed; worth retrying". An outage or a plain rate limit is still a 502, and is still retried (REM-987):
{"detail": "run …: the model provider answered HTTP 401 for model openai:gpt-4o-mini:
the provider refused the credential; check the key and its permissions;
the key is read from OPENAI_API_KEY", …}
update agentic.agents set model = 'openai:gpt-4o-mini' where name = 'harbourmaster';
curl -N http://localhost:8080/chat \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"model": "harbourmaster",
"messages": [{"role": "user", "content": "What has Meridian Dawn been cited for?"}],
"stream": true
}'
X-P8-Session-Id: 8f2c… (also in every event below)
event: RUN_STARTED
data: {"type":"RUN_STARTED","run_id":"…","agent_name":"harbourmaster"}
event: TOOL_CALL_START
data: {"type":"TOOL_CALL_START","tool_name":"query","tool_call_id":"…"}
event: TOOL_CALL_END
data: {"type":"TOOL_CALL_END","tool_call_id":"…","is_error":false}
event: TEXT_MESSAGE_CONTENT
data: {"type":"TEXT_MESSAGE_CONTENT","message_id":"…","delta":"Two port state "}
event: RUN_FINISHED
data: {"type":"RUN_FINISHED","status":"succeeded"}
On a stock compose stack your stream has the shape of that capture. The sample
binds harbourmaster to the query server, so the stream has a TOOL_CALL pair
for each query it makes, and the answer names what the rows said. An agent with
an output schema, which the sample's harbourmaster is, streams its answer as
TEXT_MESSAGE_CONTENT deltas carrying the JSON its properties describe, and
"stream": false returns the same JSON as the completion's
choices[0].message.content. The question names a Meridian vessel because the
token from install carries Meridian's
org, and a Kestrel vessel is invisible to it.
Continuing the conversation is echoing back the session id you were handed, and
watching it from another client is a second endpoint. That endpoint answers
404 to a caller who cannot read the session, the same answer as for an id
that does not exist, so holding somebody else's session id is not enough to
listen to it:
curl -N http://localhost:8080/chat -H "X-P8-Session-Id: 8f2c…" …
curl -N http://localhost:8080/sessions/8f2c…/events
Why it works — the common shape on the way in, this system's richer one on the way out
model names an agent, not an LLM. That single reuse is what lets an
off-the-shelf OpenAI-shaped client drive this runtime with nothing bespoke in
it; the LLM actually called comes from the agents row. A client's own tools
field is accepted by the shape and ignored, because an agent's tool
surface is whatever tool_servers its row points at, and honouring
caller-supplied tools would put per-request tool binding straight back in.
The updated runtime accepts tool_choice: "none" for an authoring turn. It
removes tool bindings and automatic chained actions for that request, leaving
the stored agent unchanged. This is how the workflow designer asks
for a proposal using supplied schema without executing the proposed work.
Check the runtime's OpenAPI ChatRequest for support before relying on this
option; older runtimes ignore fields they do not recognise. Using auto or
omitting the field keeps the agent's configured behaviour.
The thread id travels as a header rather than only a body field, and that is the part that carries the weight: the inbound contract is the OpenAI chat shape, which has nowhere to put one, so a client using a stock SDK can only reach for a header. An unknown or unowned id fails with a status code before the stream opens, never as an event inside it — once the response has begun the status is already sent, and "your session id was wrong" is not something a client should have to parse out of a stream. Resume needs nothing else, because history is reloaded from rows.
stream picks between the two shapes and defaults to false, because that
is what the shape being borrowed defaults to. false returns one OpenAI
completion object; true returns the AG-UI event stream above, which is the
thing PostgREST structurally cannot do and the reason this endpoint exists.
The endpoint is served at /v1/chat/completions as well as /chat, and the two
facts belong together: a stock OpenAI client builds its path from base_url and
sends stream: false, so a runtime that served only /chat and refused
stream: false — which is what this said, and did — could not be driven by one,
whatever the sentence about being OpenAI-shaped claimed. agent: workflow steps
hit the same wall, since the compiler emits that path and reads
choices.0.message.content.
Buffering is not a fake mode: the turn is run to completion and its persisted
assistant message is returned, which is the read from messages_api that
callers were being told to make for themselves. A run that fails is an HTTP
error here, which the streaming path cannot do — once a 200 and the first event
have gone out, RUN_ERROR is something the client has to parse out of the
stream.
The second endpoint is not a convenience — it is the proof that the stream is a
relay rather than a side effect of the request. Both endpoints read the same
per-session Postgres NOTIFY channel, so a client that drops mid-turn
reconnects and keeps receiving, a second client can watch a conversation it did
not start, and neither of them needs to know that delegation exists.
Tools are external, and they are rows
The runtime has no tool code of its own — not one, not even a trivial one. Every tool an agent can call is a reference to an independently deployed, independently versioned service.
Here we give an agent a tool without deploying any code.
select agentic.upsert_tool_server($j${
"name": "harbour-query", "kind": "mcp", "url": "http://query-mcp:8090",
"emits_citations": true
}$j$::jsonb);
query-mcp is percolate query mcp, which the compose file runs: one tool,
query, whose description is the p8ql grammar read from the database. The row
has no tool list until the runtime fetches one, and percolate sample load asks
it to. After registering a server yourself, sync it the same way:
curl -s -X POST http://localhost:8080/tools/harbour-query/sync \
-H "Authorization: Bearer $TOKEN"
The server forwards the caller's token to PostgREST, so its origin,
http://query-mcp:8090, is on the agent service's P8_TOOL_AUTH_ORIGINS
beside the runtime's own. Without it PostgREST answers as nobody, and every query
is refused.
An agent then names that server, and optionally narrows which of its tools it may use:
agent | server | allowlist
---------------+---------------+-----------
harbourmaster | harbour-query | ["query"]
Why it works — tools are discovered rather than declared, and the allowlist is a list
A tool server is registered once — url, kind (mcp or openapi),
credential_ref, and a cached list of what it currently exposes, fetched at
registration by reading the server's own schema document rather than keeping a
hand-written copy that drifts. agents.tools is a list of references into that
table, so the same service bound by ten agents is one row and not ten copies of
a URL and its credentials.
The key is tools, plural, and it takes a list. Writing "tool": "query"
is accepted by the database, which stores the array as opaque JSON, and then
dropped by the runtime with no message — leaving the agent with no allowlist at
all,
which means every tool that server exposes. It widens access and reports
success, which is the worst shape a mistake can have.
An OpenAPI server is an adapter onto the same canonical shape MCP already
defines, never a second parallel mechanism, and the adapter carries no
per-service knowledge: operationId becomes the tool name, parameters merge
into one input schema, method and path become the invocation target. Two things
the first real binding taught: the document is frequently Swagger 2.0 rather
than OpenAPI 3, and the base URL for calls is where the document was fetched
from, not what the document claims — host/basePath and servers routinely
lie behind a proxy, and preferring them trades a fact for a claim.
There is no in-process tool registry, which is the break from the
system this replaces. A tool mapped by name in a Python dict is code you have to
deploy in order to change; a row is a row precisely so that changing it is an
UPDATE. emits_citations is a flag on the server rather than a naming
convention, because whether responses are citable retrieval results is a
property of the server: this collection's query server sets it, a ticketing API
does not.
Delegation is an ordinary tool call
An agent that may call other agents does so through the same mechanism it uses for everything else, because there is no second mechanism.
Here we let a researcher agent hand work to an analyst, without either of them knowing anything the other does not.
The runtime serves its own agents as MCP tools at /mcp, on the port that
serves /chat, so the tool server's address is the agent service itself.
select agentic.upsert_agent($j${
"name": "analyst",
"system_prompt": "Answer the question you are handed in two sentences."
}$j$::jsonb);
select agentic.upsert_tool_server($j${
"name": "p8-agents", "kind": "mcp", "url": "http://agent:8080/mcp",
"serves_agents": true
}$j$::jsonb);
select agentic.upsert_agent($j${
"name": "researcher",
"tools": [{"server": "p8-agents", "tools": ["agent__analyst"]}]
}$j$::jsonb);
A registered server has no tool list until it is synced, and until then a turn
for researcher fails with tool server 'p8-agents' has no discovered tools.
The runtime does the sync, over the address it calls during a turn, which is a
compose name only it can resolve. Each agent is one tool, so sync again after
adding an agent:
curl -s -X POST http://localhost:8080/tools/p8-agents/sync \
-H "Authorization: Bearer $TOKEN"
{"name":"p8-agents","tools":["agent__analyst","agent__harbourmaster","agent__researcher"]}
A delegated turn runs as the person who asked the researcher, so the call needs
that person's token, and the runtime sends a caller's token only to the origins
in P8_TOOL_AUTH_ORIGINS, its own included. The compose file sets it to
http://agent:8080 on the agent service; the chart's value is
agent.toolAuthOrigins. A tool server whose origin is not on the list gets
neither the token nor the X-P8-* identity headers, and the gateway refuses a
call without them: a verified caller is required. Entries are exact
scheme://host:port origins, comma-separated, with no wildcard.
Why it works — the runtime serves its own agents as an MCP server, so delegation needs no built-in tool
The rule that the runtime has zero built-in tools left a hole: there was no
legal way to say "agent A may delegate to agent B". A hardcoded
delegate(agent) tool would have been the first built-in tool and would have
broken the rule everything else rests on. The resolution keeps it intact — the
runtime exposes its own agents as an MCP server, one tool per agent, so
delegation becomes an ordinary tool_servers reference. The gateway is the same
deployed runtime rather than a second service: agent serve answers MCP at
/mcp, and percolate agent gateway serves the same tools on their own for a
deployment that wants delegation on separate replicas.
The token goes only to listed origins because it is the caller's identity. A
registered third party that received it could act as that person, and one that
received only the X-P8-* headers could still read who they are and which
session they are in, so both follow the one list. The gateway is first-party
and needs both: it cannot run a turn as nobody, and it refuses rather than pick
an identity.
That makes the per-server allowlist do double duty: tools: ["agent__analyst"]
is how you narrow which agents a researcher may delegate to, using the same
field that narrows which query operations it may call.
serves_agents is a flag rather than a name prefix, and for a sharper reason
than emits_citations. Delegation decides how the turn is persisted and hands
the callee a delegated-by header, so detecting it from an agent__ prefix meant
any registered MCP server could claim that namespace — and a server merely
named agent produced it by accident through collision-prefixing.
A delegation is persisted differently from an ordinary tool call. Ordinary calls
ride along as JSONB on the assistant row; a delegation gets its own tool_call
and tool_response rows, written during the turn rather than at the end,
because the sub-run's row has to point back at the call that spawned it. A
runtime that persists a whole turn atomically at the end cannot populate that
column at all, and the delegation tree ends up walkable in only one direction.
Nothing new appears on the wire. A delegation is already a tool call, so the
sub-agent's entire streamed turn is the payload of a TOOL_CALL_START /
TOOL_CALL_END pair in the parent's stream, correlated by the sub-run's own
run_id. Every run in a session publishes to one channel, so a client renders
"delegating to the analyst" as an in-progress tool call without the protocol
needing to know delegation is happening. The delegation tree is also the span
tree: parent_span_id is the parent run's own span_id, not something
maintained separately.
Sharing an agent with a team
Who may use an agent is decided by the same row policy that decides who may read it, so sharing one with a team is a column on the row rather than a setting in a service.
Here we give Meridian's office a dispatcher they can all use, and keep a second agent nobody in the office can see.
select (agentic.upsert_agent($j${
"name": "harbour_desk",
"system_prompt": "Route the request to whoever on the desk handles it.",
"org_id": "d0000000-0000-0000-0000-00000000000a", "visibility": "org"
}$j$::jsonb)).name;
select (agentic.upsert_agent($j${
"name": "night_audit",
"system_prompt": "Review the day's port calls for anomalies.",
"visibility": "private"
}$j$::jsonb)).name;
A member of Meridian reads the first and not the second, through the same view every client lists agents from. Here we ask as the sample's Meridian reader, and switch back afterwards:
select set_config('request.jwt.claims',
'{"sub": "e0000000-0000-0000-0000-00000000000a",
"orgs": ["d0000000-0000-0000-0000-00000000000a"]}', false) is not null as as_meridian;
set role authenticated;
select name, visibility from agentic.agents_api
where name in ('harbour_desk', 'night_audit') order by name;
reset role;
reset request.jwt.claims;
name | visibility
--------------+------------
harbour_desk | org
(1 row)
Why it works — reach is a row policy, so a router hands on only what its caller may read
visibility is one of private, org and public, and org_id says which
organisation an org row belongs to. The read policy on agentic.agents admits
a row that is public, that the caller owns, or that belongs to an organisation
the caller is a member of, and nothing else. Membership is a row in
rbac.org_members, so adding a person to the office is how they get the
office's agents, and removing them takes the agents away at their next request.
Invoking is the same question asked once more. agentic.may_invoke(name) is true
only when the caller may read the agent and holds agents:invoke, and
agentic.start_run asks it before a run exists, so a request for an agent the
caller cannot see is refused rather than run:
select agentic.start_run('night_audit')ERROR: not authorized to invoke agent night_audit
HINT: needs agents:invoke, and the agent must be visible to you
Delegation follows from that. A router's allowlist says which agents it may try, and the database says which of those each caller's request may reach, so one router shared with a whole office hands a member's request only to agents that member could have called directly. The router is offered that same list: its catalogue of agents is built per caller, from the agents the caller may read, so a member's router names the office's shared agents and their own and nothing of anyone else's. The refusal above holds either way, because it is the database's.
The rest of what the row carries
Everything above is the loop. These are the columns you reach for once one agent is running and you want it to behave differently.
Here we bound a long conversation, fire an action on the structured output, and keep the table usable at a thousand rows.
select agentic.upsert_agent($j${
"name": "harbourmaster",
"audience": "user",
"category": "tool_user",
"tags": ["fleet", "compliance"],
"context_policy": {"window_messages": 40, "summarize_after_messages": 60},
"chained_action": {"kind": "pg_function", "target": "agentic.apply_summary"}
}$j$::jsonb);
Why it works — the numbers are per-agent data, because the right number is still an open question
context_policy is the persistence adapter's reload policy. The default is
windowing plus a reserved slot for a rolling summary, never "everything ever
said in this session", and the parameters live on the row rather than as
constants in the adapter because message-count-versus-token-estimate is
genuinely unsettled. summarizer_agent_id names which agent summarizes the
windowed-out history — a nullable self-reference, so "who summarizes" is data
too.
summarize_after_messages is when the summary fires. Once a session holds that
many messages, or a turn fills three quarters of a model's declared context
window, the runtime starts the summarize_session_window workflow. Set it per
agent, as the statement above does. An agent that leaves it out takes
P8_SUMMARIZE_AFTER_MESSAGES from the runtime's environment, 40 when unset, and
P8_SUMMARIZE_AT_CONTEXT_FRACTION, 0.75, moves the other trigger. The compose
file passes neither to the agent service, so add them under its
environment:; the chart takes them in agent.env.
A stock install has no summary workflow. Neither the extension nor this
repository installs summarize_session_window, or the summarizer agent it
calls (P8_DEFAULT_SUMMARIZER). Turns still succeed. Past the threshold the
oldest messages leave the window unsummarized and nothing fills the summary
slot, and the runtime says so when it starts, with a warning that begins
sessions will not be summarized: no workflow definition
'summarize_session_window'.
The window is scoped to the branch, not the session. A session-scoped window
hands a delegated sub-agent the parent's rows, including the tool_call row
representing its own invocation, which is both wrong and confusing to the model.
A run reloads the messages of the runs sharing its parent: null for a
top-level run, so successive turns are one continuous history; the parent's run
id for a delegated one, so a sub-agent sees its own turns and nothing of the
conversation that called it.
chained_action is p8k8's "chained tool call" reframed as data rather than a
Python callable lookup. A pg_function target has the signature
(jsonb) -> jsonb and receives the structured output plus the run context
that produced it — {"run_id", "session_id", "agent", "output"} — because an
action given only the model's output cannot write anything scoped to the
conversation it came from, which is exactly what a summarizer needs. It fires
whenever the agent has both a schema and an action: under pydantic-ai the output
either validates or the run fails, so there is no third state to test for.
audience is an access decision and is not derived from tags.
Whether a human may pick an agent should not be something a typo can change, and
once this table holds thousands of rows a picker showing a person a thousand
machine extractors is broken. category is a closed list for the same reason:
an open one becomes a synonym pile within a month, and filtering was the point.
Citations are derived, not asked for
Here we know what an agent actually had in front of it when it answered.
select seq, role, citations from agentic.messages_api
where session_id = (select id from agentic.sessions_api order by created_at desc limit 1)
order by seq; -- your latest conversation; put its X-P8-Session-Id here for another
Why it works — asking the model for its own citations does not work well
messages.citations is populated by the runtime, mechanically, from the
tool_response rows between the previous assistant message and this one — for
every tool server flagged emits_citations, the resource_id and chunk_id of
every row the query actually returned.
Models drop citations, invent ones that do not match what was retrieved, or cite generically. So the list is built from what the agent's context actually contained when it answered rather than from what it says it used, and that needs no cooperation from the model at all.
Retrieval payloads themselves are not stored. A RAG result is volatile scratch: it is re-fetched next turn against a moving index, it would dominate the table by volume, and replaying it as history would feed the model stale context it did not ask for. The assistant row keeps the fact of the call and its provenance, and nothing of its payload — citations without payloads is the whole design.
Extractors: the population this is sized for
The agents you talk to will be a rounding error. Turning prose into structured
data is a cost-ordered cascade whose last tier is a specialist extractor per
(source, document type), and that population is sources × doctypes.
Here we register one specialist and have the database refuse it if the shape is missing.
select agentic.upsert_agent($j${
"name": "psc_report_extractor",
"category": "extractor",
"audience": "system",
"extracts_source": "harbour-notices",
"extracts_doctype": "psc_report",
"structured_output_schema": {"type": "object", "required": ["vessel"]}
}$j$::jsonb);
Why it works — two constraints stop this table becoming unusable at a thousand rows
category = 'extractor' requires structured_output_schema, enforced by the
schema. An extractor with no declared shape is a model call with extra steps,
since the shape is what lets you combine N extractions and what makes a bad
answer retryable rather than wrong with no sign of it.
A unique index enforces one specialist per (source, doctype), so a second registration is a conflict to resolve rather than a coin flip at runtime.
Naming one from a workflow
An agent is also a step kind, which is how a pipeline gets one without knowing any of the above.
Here we have two agents work in sequence and share one conversation between them.
- id: triage
agent: classifier
input: 'Classify: {{steps.retrieve.result}}'
session_group: analysis
- id: deep_dive
needs: [triage]
agent: researcher # may itself delegate to other agents
input: '{{steps.triage.result}}'
session_group: analysis # …so these two share one conversation
Why it works — an agent step is a REST step whose URL and auth the compiler fills in
It compiles to POST {{env.P8_AGENT_URL}}/v1/chat/completions on the http
queue with credential_ref: P8_API_KEY — the same completions endpoint a stock
OpenAI client calls, read back from choices.0.message.content. The call is
synchronous, so the worker holds the connection for the length of the turn.
The worker needs P8_AGENT_URL, which the compose file and the chart set, and
no token of yours. It asks the database who owns the run and signs a
five-minute token for that person with the key file P8_AGENT_SIGNING_KEY_FILE
names; both deployments mount it from the JWT secret the runtime verifies with.
So the agent answers as the person who started the run, and a run with no owner,
started from a psql prompt with no claims, fails at once with Agent task has
no verified run owner. Start it over REST with a token instead.
credential_ref: P8_API_KEY is how the worker recognises the step, not a
variable it reads. It reads P8_API_KEY only when it signs nothing: a worker
started without P8_AGENT_SIGNING_KEY_FILE, or a rest: step naming that
credential whose url is not {{env.P8_AGENT_URL}}/v1/chat/completions or
{{env.P8_AGENT_URL}}/internal/run written that way. Even then it sends the key
only to an origin P8_CREDENTIAL_ORIGINS binds to P8_API_KEY, and the
shipped default binds none.
session_group asks the engine for a session id that is stable for the life of
the run and bound to {{run.$session}}. Steps naming the same group share a
conversation, two groups are two independent threads, and declaring neither
stays stateless, which is the right default for a classification step.
Before this existed a workflow could not start a session of its own:
session: only resolved if the caller had put an id in the run input, and the
natural key comes from the run, which the run could not see until {{run.$id}}
existed.
Nested delegation happens inside the runtime rather than being unrolled into the workflow graph, so a researcher agent calling an analyst is one task here and a delegation tree there.
An agent on a schedule
An agent: step holds the worker's connection for the whole turn. For a turn
nobody is waiting on, such as a nightly check, the step posts to the runtime's
/internal/run instead: the runtime answers 202 at once and completes the
task itself when the turn ends.
Here we run the harbourmaster every night, and have the workflow finish when the agent does.
select workflow.define_yaml($$
name: nightly_harbourmaster
steps:
- id: overdue_inspections
queue: http
rest:
url: '{{env.P8_AGENT_URL}}/internal/run'
method: POST
credential_ref: P8_API_KEY
mode: async
wait_ms: 1800000
body:
workflow_task_id: '{{task.id}}'
input:
agent: harbourmaster
prompt: 'Which vessel is most overdue for inspection?'
$$);
Then put it on a schedule as yourself, with the $TOKEN from
install:
curl -X POST http://localhost:3000/rpc/schedule_workflow \
-H 'Content-Type: application/json' \
-H 'Content-Profile: workflow' \
-H "Authorization: Bearer $TOKEN" \
-d '{"p_name": "nightly-harbourmaster", "p_workflow": "nightly_harbourmaster", "p_cron": "0 3 * * *"}'
A missing agent fails in seconds, not at wait_ms. If input.agent names
an agent that does not exist, or one the run's owner cannot see, the runtime
fails the task itself: failed after one attempt, terminal set, and
AgentNotFound: no agent row named '…' as the error. The worker does not
retry it.
Why it works — the worker dispatches the turn and the runtime finishes the task
The body is what the runtime reads. workflow_task_id names the task it will
complete, and {{task.id}} is this step's own; input.agent names the agent
and input.prompt is the turn. A body without workflow_task_id or
input.agent is refused with 400, naming the missing field.
mode: async makes the worker send the request and then hold the task
running, heartbeating, until the runtime completes or fails it. wait_ms
bounds the hold, and is fifteen minutes when omitted. Past it the worker fails
the task terminally rather than start a second turn. The hold occupies that
worker, so run one http worker for each scheduled turn you expect in flight at
once.
The worker signs the call for the run's owner, as it does for an agent: step,
and a scheduled run's owner is whoever called schedule_workflow. That is why
the schedule is made over REST: one made at a psql prompt with no claims has no
owner, and every fire fails with Agent task has no verified run owner. The
definition made at the prompt belongs to no one, so the administrator install
bootstraps can schedule it and other users need a definition of their own.
Nothing fires until pg_cron runs workflow.tick().
Next: skills and plugins, the prose an agent carries and shares with its siblings — stored once as rows, picked per turn, and installed and removed as one bundle.