Three years ago we started a project called Percolate with a fairly narrow question behind it: what happens if you push an agentic system all the way down into the database?
Not the version of that where the database stores your chat history and everything interesting happens in a Python process. All of it. The agent registry, the tool definitions, the model routing, the session state, the loop itself. We wanted to find out where it broke.
Some of it was deliberately daft. Early Percolate called language models over HTTP from inside Postgres, so you could open psql and type this:
select * from percolate('What is the capital of Ireland?');
select * from percolate('briefly describe percolate', 'gemini-2.0-flash');
You get an answer back in a result set. Change the second argument and you have switched provider. That demo did a lot of work for us at the time and we still think it is the clearest thirty seconds anybody can spend on the idea.
It is also not how you should run anything. A blocking HTTP call inside a Postgres backend holds a connection and an open transaction for as long as somebody else's inference takes. A slow model endpoint stops costing you worker capacity and starts costing you connection slots, which is a far worse currency to pay in. There are other reasons, but that one is enough on its own.
The exercise was never really about whether it was practical, though. It was about what survives the compression. Pushing it as far down as it would go showed us something we had half-known and never acted on, which is that an enormous fraction of an agentic system is just data.
Almost all of it is data
Prompts are data. Tool definitions are data; an OpenAPI document or an MCP manifest is a row with a JSON column in it, and pretending otherwise buys you nothing. A conversation is the most table-shaped object in the entire stack, a sequence of turns with a foreign key to a session. Model configuration is data. Which agent may delegate to which other agent is a join table. Skills, in the sense of prompt fragments an agent carries or picks up for one turn, are rows. Evaluation runs are rows.
Almost everything we had been writing Python classes for turned out to be a schema we had not bothered to write down.
That is a slightly uncomfortable thing to notice after you have spent a while building frameworks, because it means most of the framework was ceremony. Not all of it. There is a real engine in the middle that calls a model, parses what comes back, decides whether that was a tool call, invokes the tool, feeds the result back in and goes round again, with retries and structured output validation and streaming. That part is genuine software and it is fiddly to get right. But it is much smaller than the repositories built around it suggest.
Why we didn't use anybody else's
At the time none of the agent frameworks appealed to us. They were heavy, they carried opinions about things we did not want opinions about, and none of them captured the abstraction we actually wanted. What we wanted was something we had been calling an agent-let: a small unit written as a Pydantic model, where the model is the specification. The docstring is the system prompt, the fields are the structured output, the methods or referenced functions are the tools, and the framework's whole job is plumbing. Do the retries. Do the OTel spans. Do the provider-specific message translation. Then get out of the way.
Percolate had a Python module as well as the database side, and that module was us writing that plumbing again, worse than somebody who was thinking about it full time.
Pydantic AI was the first library we saw that captured it tidily, and honestly it might still be the only one. Their approach mirrors the agent-let idea closely enough that reading their docs was a slightly odd experience. Solid engineering, sensible boundaries, no ambitions to own our application. So in the new Percolate we stopped competing with it and wrapped it.
Two adapters, and then you stop
Once you accept somebody else's engine, the question becomes what you have to add. In our case the answer is two adapters, and the fact that it is exactly two is the interesting part.
The first is an adapter to state in Postgres. Pydantic AI models a conversation
as a message_history list you pass into a run. Our adapter maps that onto
sessions, runs and messages tables in both directions: persist a turn as
it happens, and reload a bounded window of history for the next one. The reload
side is where you need an actual opinion rather than a mapping, because
"everything ever said in this session" is not a policy, it is the absence of
one. Ours windows by recency with a slot reserved for a rolling summary, and it
scopes the window to a branch rather than a session, so a delegated sub-agent
sees its own turns and nothing of the conversation that called it. We found that
one by running a delegated turn and watching a sub-agent get handed the tool call
that had invoked it.
The second is streaming out to clients. Pydantic AI already implements AG-UI, so
the wire protocol was a decision we did not have to make. What was left was
mapping AG-UI's concepts onto Postgres ones, and delegation onto AG-UI's
existing tool-call events. A sub-agent's whole streamed turn is the payload of a
ToolCallStart and ToolCallEnd pair in the parent's stream. A client renders
"delegating to agent B" as an in-progress tool call and B's output as the
contents of that call, and the protocol never needs to know delegation is a
thing.
The reason we keep calling both of these adapters rather than components is that adapters are finished. Once written, this thing does not change. It is a component we ship with Percolate and then mostly stop thinking about, which is exactly the relationship you want with your agent runtime.
Everything else is data. Prompts, skills, tool specifications, agent definitions, model registry, delegation graph. Adding an agent is an insert. The only Python in the whole picture is the tools you bring, and in practice those are microservices that already existed outside the agent framework and were never going to live inside it anyway.
What we got wrong for years
Two things we had always treated as add-ons, the bits you bolt on once the interesting work is done, are first class in the new Percolate. One is a permissions system. The other is a workflow engine.
Permissions first, briefly, because the argument is short. The moment an agent
can query your data, "which rows may this caller see" stops being an
application concern and becomes the same question as "what is this agent
allowed to know". If that lives above the database you will get it wrong,
because there will always be one more path to the data that forgot to ask. Ours
is users, roles, API keys, sessions and row-level security, sitting under
everything else rather than in front of it. We found a real hole in it by
writing the policies out longhand: scheduled agent runs have no session, every
policy scoped through the session's owner, and a null check in a USING clause
is how a table ends up with rows nobody can see and everybody can read.
The workflow engine is the bigger claim and it is the reason for the title.
Underlying the workflow
An agent framework and a workflow engine look like different products until you have built both and noticed they are the same shape. A run is a DAG of steps with dependencies, retries, partial failure, compensation, and state that has to survive a process dying halfway. That is true of a data pipeline and it is true of a multi-step agent turn. The difference is only that one of the step kinds happens to call a language model.
So in Percolate the workflow engine is not a feature next to the agent runtime.
It is underneath it. A workflow is a YAML document compiled into ordinary rows,
and a step is one of p8ql, sql, rest, embed, agent or work. The first
two execute inside Postgres the moment their dependencies are satisfied, with no
process running anywhere. Three are outbound HTTP handled by a generic worker,
and agent is one of those, which is to say an agent turn is a step kind rather
than a separate execution model. Only work needs code you wrote.
That means a pipeline of queries and graph updates completes with nothing deployed. It means retry, backoff, the reaper, saga compensation, cancellation and tracing are written once and apply to agent steps for free, because an agent step is not special. And it means the answer to "how do we run this agent nightly over yesterday's documents" is a scheduled task rather than a second system.
The query side went the same way. Percolate has always had the idea of a multi-modal query designed for a language model to write rather than a person, and that has landed as P8QL: nine modes over graph, vector and lexical search in one surface, named after the moves an investigation is made of rather than the Postgres features underneath. Resolve a name to a thing. Walk out from a thing. Find things that mean the same. Ask what exists at all. The reason to raise that above SQL is that the mistakes at SQL level are quiet. A distance operator that does not match the index opclass returns rows in the wrong order and no error. A vector from the wrong model returns a number and no error. A model is much worse placed than a person to notice, because it never gets to look at the result, frown, and go back.
Where that leaves it
Three years on, most of what felt hard at the start has become a utility you buy or install. Calling a model is a commodity. Embedding is a commodity. Streaming to a browser has a protocol now. Even the agent loop, the thing we burned the most time on, is a dependency we pin a version of.
What did not commoditise is the orchestrator: the thing that knows what is supposed to happen, what already happened, what to do when a step fails at three in the morning, and who is allowed to see the result. That is a database problem wearing an AI hat, and it is where the work actually is.
Which is why the workflow engine went underneath everything else rather than beside it, and why we would build it in that order again.