Percolate An AI workflow engine in Postgres GitHub llms.txt

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
Harbourmaster.model_json_schema(), rendered back out as YAML
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:

{"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
      }'
the response, as server-sent events
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:

the binding, as stored
     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"
the catalogue the runtime stored
{"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;
what the Meridian reader sees
     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:

the same reader, 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.