Percolate An AI workflow engine in Postgres GitHub llms.txt

The workflow grammar

A workflow is a YAML document with a name and a list of steps, and each step carries exactly one action. This page is the whole vocabulary at version 0.2.36 — every key the compiler accepts, what each one compiles to, and who executes the result.

The thing to hold onto while reading it is that none of this is a second execution engine. Every document here compiles to ordinary workflow.tasks rows, so retry, backoff, the reaper, saga compensation, cancellation and tracing all apply to a YAML workflow exactly as they apply to a hand-built one.

The document

name: <required, the workflow's name>
version: <optional int, default 1>
steps:
  - id: <required, unique within the document>
    needs: [<step ids this one waits for -- an AND-join>]
    queue: <which worker pool claims it; in-database kinds ignore it>
    rate_key: <a shared throttle consumed before the task can be claimed>
    compensate_with: <step id to run in reverse if a saga member fails>
    saga_group: <groups tasks compensated together>
    when: <a SQL predicate; false or null skips the step>
    <exactly one action, from the table below>
  - id: <a group of steps tried again until a check passes>
    needs: [...]
    repeat: {max: <1 to 20>, until: <a SQL predicate>, steps: [<steps, as above>]}

Unknown keys are refused by name rather than ignored, which is deny_unknown_fields doing its job and is why a typo in a step key is a compile error rather than a missing feature that reports nothing.

when: and repeat: are below. They are newer than the rest of this page: an installed compiler that lists both under accepts in compiler_capabilities() has them.

The nine actions

Each step carries exactly one. The compiler rejects zero or two, and says which step.

YAML compiles to who executes it
p8ql: '<query>' kind: sql nobody — runs inside Postgres
sql: '<statement>' kind: sql nobody — the SQL you wrote, read-only
sql: {statement, write: true} kind: sql nobody — SQL that changes the database
sql: {function, args} kind: sql nobody — a registered function
rest: {url, …} kind: http_call the worker's built-in handler
embed: <text> kind: http_call the worker, at a URL from the registry
agent: <name> kind: http_call the Agent Runtime
work: true kind: work you — the only case needing your code
matrix: {…} kind: matrix nobody — expansion is a transaction
signal: true kind: signal a person, via signal_task
timer: <seconds> kind: timer nobody — a clock
workflow: <name> kind: sub_workflow nobody — the child expands in-transaction

The last three are control steps. They wait rather than act, which is why declaring one alongside an action is refused.

Queries that need no process

Here we run a graph walk and a lexical search as workflow steps, with nothing deployed anywhere.

name: fleet_brief
steps:
  - id: operator
    p8ql: 'LOOKUP "MERI"'
  - id: fleet
    needs: [operator]
    p8ql: 'GRAPH "MERI" DEPTH 1'
  - id: reports
    needs: [operator]
    p8ql: 'TEXT "PSC-441" FROM chunks LIMIT 3'
workflow.tasks after start_workflow returns
 step_key | kind |  status   |   worker    | rows_out
----------+------+-----------+-------------+----------
 fleet    | sql  | succeeded | in-database |        4
 operator | sql  | succeeded | in-database |        1
 reports  | sql  | succeeded | in-database |        2
Why it works — an in-database step executes in the transaction that makes it ready

A sql or p8ql step becomes ready the moment its dependencies resolve, and autorun_sql_steps executes it inside that same transaction. There is no queue to poll and no process to deploy, which is why the run above has already finished by the time start_workflow returns to you.

The rows land on the task, so the graph walk the middle step did is readable straight out of workflow.tasks.output rather than having to be recomputed.

One thing to know lives here, and it is the ninth P8QL mode. A p8ql: step holding plain SQL executes, and inside a step the invoker is the engine owner — which owns every table and is not subject to their row-level security. So such a step reads across tenants. It is a beta trade and percolate.sql_policy = 'registered' refuses it from 0.2.30 onward; the cookbook has the whole of it.

sql steps run SQL

Here we ask the database a question, in a workflow, without ceremony.

  - id: quarters
    needs: [land]
    sql: "select fy, fp, val from sec.facts where cik = {{run.cik}} order by fy desc"
the step's result
[{"fy": 2026, "fp": "Q3", "val": 109420000000},
 {"fy": 2026, "fp": "Q2", "val": 111180000000}]

A statement is read-only. One that changes the database says so, and is refused if it does not:

  - id: purge
    sql:
      statement: "delete from staging where run_id = {{run.$id}}"
      write: true

And a registered function stays available, which is what the engine's own steps use:

  - id: reindex
    sql: {function: rebuild_graph_index, args: [company]}
Why it works — two guards, and only one of them is the boundary

A read is wrapped as select … from (<your statement>) t and runs with transaction_read_only on. The wrap is structural: Postgres refuses a data-modifying WITH anywhere but the top level, so with gone as (delete …) fails on its shape rather than on a keyword list. The read-only transaction catches everything else, including a volatile function that writes, which no wrapping sees. The keyword check beside them is a courtesy that turns an abort into a sentence naming write: true.

Every {{template}} becomes a bound parameter. The statement is rewritten so each reference reads $1->>'<ref>' and the values are bound alongside it, so a run input of '; drop table x; -- is a string — not because it was escaped, but because it never reaches the parser. Quotes you write yourself are consumed rather than doubled, so cik = '{{run.cik}}' and cik = {{run.cik}} mean the same thing.

This covers {{item.*}} inside a matrix: template as well, which matters because those values are rows rather than something the author typed. A JSON number binds as ::numeric and a boolean as ::boolean, so where n = {{run.n}} still works against an integer column; a text value compared against a non-text column needs your own cast, because ->> yields text and text is not coerced to the column's type for you.

The one thing you cannot template is an identifier — from {{run.table}} becomes from ($1->>'run.table') and fails with syntax error at or near "$1". That is the parameterisation working, not a gap: values can be bound and names cannot, which is where every database driver draws the same line.

What it costs. A statement runs as the engine owner, which owns every table and bypasses row-level security, and so does every p8ql: step, dialect modes included. Anyone who may define a workflow may read anything in the database. That is the trade: the alternative made every question a privileged human had to bless.

In 0.2.36 it also reaches whoever starts the run, and anyone signed in may start any workflow: a member who runs a retrieval workflow an admin wrote reads every org's documents through it. The next extension release refuses such a step when the person who started the run may not define workflows. The cookbook says what to do until then.

A deployment can also refuse the SQL an author writes —

alter database <db> set percolate.sql_policy = 'registered';

— and statements are refused at authoring time and, from 0.2.30, at dispatch too, with registered functions still running. That narrows what an author may write. It does not stop a p8ql: SEARCH reading as the owner, because p8ql is itself a registered function.

Registering a function, and what it still buys

Here we bless an operation the deployment wants reviewed, and give it a description a model can read.

select workflow.register_step_function(
    'rebuild_graph_index', 'aiq.rebuild_entity_type', array['text'],
    p_description => 'reproject one entity type into the node registry');
Why it works — a timeout, a description, and an inventory

The registry stopped being a gate and kept the jobs it was always better at. A registered function carries its own timeout_ms, and its description is the same text a bound tool takes into a model's context — so workflow.step_functions is the answer to what can a workflow cause to run for the operations you chose to bless. Under sql_policy = 'registered' it is also the whole surface.

Registration checks one thing that is easy to get wrong and expensive to discover late. execute_sql_step is SECURITY DEFINER, so your function runs as its owner rather than as you — and if that role cannot reach your schema, registration refuses with the grant that fixes it rather than succeeding and failing at every run:

register_step_function
ERROR:  function harbour.land_notices(jsonb) is not reachable by app_owner,
        which is the role that will execute it (execute_sql_step is SECURITY
        DEFINER). Registration would succeed and every invocation would fail
        with "permission denied for schema harbour". Run:
        grant usage on schema harbour to app_owner;

The check is on the table, not on this function, and the difference is the one that makes the inventory worth trusting. A plain insert into workflow.step_functions is refused by the same rule with the same message, so there is no door into the registry that skips it — including one you reach by reading the table before this page. Registering is owner-only either way; what the trigger adds is that an owner cannot do it by accident.

rest steps, and where the credential lives

Here we call an external API and keep the key out of the database.

  - id: notify
    queue: http
    rest:
      url: '{{env.OPS_WEBHOOK}}/incidents'
      method: POST
      credential_ref: OPS_TOKEN
      timeout_ms: 30000
      mode: async
      wait_ms: 600000
      jsonpath: data.id
      body: {summary: '{{steps.brief.result}}'}
Why it works — the whole `rest:` mapping becomes the task input, so these are keys and not a schema

The compiler copies the mapping under rest: into workflow.tasks.input verbatim, which is why url, method, body, headers, timeout_ms, jsonpath, mode, wait_ms and credential_ref are all available without the grammar enumerating them: the worker's handler is what reads them, and the compiler does not need an opinion.

credential_ref is a name, resolved by the worker from its own environment. A dump of workflow.tasks therefore never contains a secret and a task stays inspectable and replayable. {{env.X}} works the same way and is resolved only by the worker — a sql: or p8ql: step cannot read it, because the database has no business knowing the deployment's environment.

mode: async sends the request and then holds the task until the callee calls complete_task or fail_task. While it holds, the worker heartbeats the lease and writes nothing; when the callee finishes, the worker lets go. wait_ms bounds the hold, and is 15 minutes when omitted. Past it the worker fails the task terminally, because a retry would send a second request to a callee that may still be working. A wait_ms that is not a positive whole number of milliseconds is refused before anything is sent.

The hold occupies the worker. percolate worker runs one task at a time, so a worker holding an async step claims nothing else until the callee answers or wait_ms passes. Run as many http workers as you expect async steps in flight at once, plus the ones your synchronous steps need.

That is the seam a scheduled agent run uses: its step posts to /internal/run, and the runtime completes the task when the turn ends, or fails it with a verdict when the turn does. An agent: step does not use it; it is the synchronous call below.

A vector query is two tasks

Here we search a corpus by meaning from one authored step, without writing the model call.

  - id: retrieve
    p8ql: 'SEARCH "{{run.question}}" FROM chunks LIMIT 5'
Why it works — the compiler writes the embed step, and pins the model into both halves

SEARCH and SEMANTIC rank against a vector, and the database makes no model calls. So the compiler expands that one step into an http_call keyed retrieve__embed plus the sql step keyed retrieve that consumes it. Your id stays on the search, so needs: [retrieve] and {{steps.retrieve.result}} mean what they look like and nothing downstream is rewired — the embed is a hidden predecessor, not a replacement.

You never write the URL. It comes from aiq.embedding_models, and USING <model> is written into the query if it was not there and refused if it disagrees with the embed step's model. A vector from one space ranked against another is not an error at runtime, only a meaningless number, so it is made a compile error instead.

queue: on the step routes the embed half; the search half runs in-database and is claimed by nobody. The hand-written pair — embed: then sql: {function: p8ql_vec, …} — is still legal and compiles to exactly the same rows.

agent steps, sessions and declared shapes

Here we have a model answer with a shape we can put in a column, and have two agents share one conversation.

  - id: triage
    agent: classifier
    input: 'Classify: {{steps.retrieve.result}}'
    session_group: analysis
    output_schema:
      type: object
      required: [verdict, confidence]
      properties:
        verdict:    {type: string, enum: [SAFETY, FINANCE, OTHER]}
        confidence: {type: number}

  - id: deep_dive
    needs: [triage]
    agent: researcher
    input: '{{steps.triage.result}}'
    session_group: analysis        # …so these two share one conversation
    jsonpath: ''                   # capture the envelope, not just the text
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, and reads the answer 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 chart set, and no token: it signs one for the run's owner (why). Nested delegation happens inside the runtime rather than being unrolled into the workflow graph, which is why a researcher calling an analyst is one task here and a delegation tree there.

session_group asks the engine for a session id, stable for the life of the run and bound to {{run.$session}}. Steps naming the same group share a conversation, two groups are two threads, and declaring neither stays stateless — the right default for a classifier. The step sends the id as session_id, which is the key the Agent Runtime reads, and the engine creates the agentic.sessions row for it, owned by whoever started the run, the first time a step needs it, so the runtime has a conversation to continue rather than an id it answers 404 to.

output_schema is what makes the answer composable: the engine parses the model's JSON-in-a-string reply and checks it before storing, so the next step receives an object rather than prose. A shape violation is the one retryable failure in this engine — every other terminal classification exists because retrying cannot help, and here the same prompt genuinely can conform next time.

It is a shape check and not JSON Schema. Types including integer, required, properties, enum, items and additionalProperties: false are supported; $ref, allOf/anyOf/oneOf, pattern, format and numeric bounds are not, and are named here rather than ignored without a word. Declaring output_schema on a sql or matrix step is refused, because the check is for output this engine did not produce.

matrix — the work to do is a query result

Here we turn one authored step into N tasks, with nothing outside the database deciding how many.

  - id: extract
    matrix:
      rows: "select id, content from reports where extracted_at is null"
      max_fanout: 500
      continue_on: failed
      min_success: 0.9
      template:
        rate_key: openai-completions
        agent: psc_report_extractor
        input: '{{item.content}}'

  - id: land
    needs: [extract]
    sql: {function: land_extractions, args: ['{{steps.extract.result.task_id}}']}
Why it works — the children are inserted by the statement that completes the parent

There is no window in which extract is succeeded and the children do not exist yet. A controller-based fan-out has that window, and a controller that dies inside it strands the fan-out with nothing to resume from.

max_fanout is mandatory and the compiler says why rather than defaulting: a cross join with a forgotten WHERE expands to the cartesian product, and that should fail one step rather than the database. rows: takes the query itself or a registered function, exactly as sql: does — with one difference: a row source is always read-only, because it is re-run by every retry of the expansion.

continue_on: failed lets a failed child satisfy its dependents, and min_success is the floor below which the aggregate is cancelled rather than run on partial data — a fraction if it is ≤ 1, an absolute count if it is > 1. Declaring min_success without continue_on is refused by name, because the first failed child would cancel the successors and the threshold could never be reached. It is a transport floor: a 200 carrying an empty body counts as a success against it.

The fan-in reads a handle, not a payload. {{steps.extract.result.task_id}} is the matrix task and workflow.matrix_outputs(task_id) pairs every child with its row, status and output. Five hundred extractions are for aggregating inside your own function, not for passing through a step argument that caps at 64KB.

One sharp edge to check before you rely on it: a matrix template cannot declare output_schema. The compiler accepts queue, rate_key, rest, embed, agent, work, input, session and jsonpath inside a template and not that, so a fan-out child lands the model's answer as a JSON string and your fan-in parses it.

Control steps — waiting for a clock, a person, or a child

Here we hold a run open until a harbourmaster signs off, with a cooling-off period first and no process doing the waiting.

  - id: cooling_off
    needs: [notice]
    timer: 3600
  - id: approve
    needs: [cooling_off]
    signal: true
  - id: issue
    needs: [approve]
    workflow: issue_notice        # a child workflow, expanded in-transaction
select workflow.signal_task(
    (select run_id from workflow.tasks
      where step_key = 'approve' and status = 'waiting_external'
      order by created_at desc limit 1),
    'approve', '{"decision":"released","by":"harbourmaster"}'::jsonb);

The subquery finds the newest run waiting on approve. From psql it needs the identity claim that your first workflow sets, because signal_task checks who is signing and refuses a session that carries nobody. It returns how many waiting steps it resolved: 1 here, and 0 for a signal sent before the step is waiting_external, a second answer, or a misspelled name, none of which changes anything. Send a signal once waiting says the step needs you, and read the count.

Why it works — waiting is a row state, so nothing holds a process open to do it

A timer moves when workflow.promote_due_timers() runs on the pg_cron minute that already ticks the engine. A signal step then sits in waiting_external, which is the status meaning a person is the dependency, and only signal_task moves it. Both ask whether the run is finished once they have moved a step, as complete_task does, so a run whose last step is a signal or a timer ends succeeded when that step does.

signal_task checks who you are, and a run with no owner is not owned by everyone: it takes an explicit permission against the run's owner, set from the verified JWT of whoever started it. That is also why a scheduled run carries its schedule's owner — otherwise a scheduled workflow that waits for approval could never be approved, by anybody.

All three of these were legal engine kinds that no document could reach until somebody tried to write exactly this workflow and the compiler refused it. And sub_workflow had never executed at all: start_child_workflow inserted a run row without expanding the child's task graph, so composition was advertised, unreachable, and broken underneath.

when: — a step that runs only if

A step can carry a condition over the run's input and earlier results. When it does not hold, the step is skipped and the run carries on.

Here we send a claim to review only when its score is over 0.5, and pay it either way.

select workflow.define_yaml($y$
name: claim_gate
steps:
  - id: score
    sql: "select {{run.score}} as v"
  - id: review
    needs: [score]
    when: "{{steps.score.result.0.v}} > 0.5"
    sql: "select 'reviewed' as r"
  - id: pay
    needs: [review]
    sql: "select 'paid' as p"
$y$);

select workflow.start_workflow('claim_gate', '{"score": 0.2}'),
       workflow.start_workflow('claim_gate', '{"score": 0.9}');

select r.input->>'score' as score, t.step_key, t.status, r.status as run
  from workflow.runs r
  join workflow.definitions d on d.id = r.workflow_def_id
  join workflow.tasks t on t.run_id = r.id
 where d.name = 'claim_gate'
 order by score, t.id;
the two runs
 score | step_key |  status   |    run
-------+----------+-----------+-----------
 0.2   | score    | succeeded | succeeded
 0.2   | review   | skipped   | succeeded
 0.2   | pay      | succeeded | succeeded
 0.9   | score    | succeeded | succeeded
 0.9   | review   | succeeded | succeeded
 0.9   | pay      | succeeded | succeeded
Why it works — skipped is a terminal success, so nothing after it waits or is cancelled

The engine asks the predicate once, when the step's needs are done and it would otherwise become ready. True runs the step. False or null marks it skipped, which workflow.lifecycle_states records as terminal and a success, so the steps after it are released as they would be after a success, and a run of successes and skips has succeeded. A skipped step is never claimed, a skipped signal never waits for anybody, and a skipped step is never compensated, because it did nothing.

Each {{template}} in a predicate is a bound value, as in a sql: statement, and a step it names must be in needs; the definition is refused otherwise. A skipped step's result is null, so a template naming it binds NULL and a comparison with it is null, which skips too. when: "{{steps.review.result.0.r}} is null" is therefore how a step asks whether review was skipped. A gate is a when: on every step it guards: pay above runs after a skipped review because it has no condition of its own.

The predicate runs through workflow.run_sql, as a sql: step does: read-only, under percolate.sql_policy, and as the run's owner, so row policies apply to whatever it reads. One that cannot be evaluated fails its step and the run, with when: <the error> as the step's error (failure).

repeat: — try again until a check passes

A group of steps can run again until a check over its own results holds, up to a number of attempts stated in the document.

Here we count up one attempt at a time until the count reaches the run's target, at most four times. Each attempt reads the one before it, and the check holds at the second.

select workflow.define_yaml($y$
name: count_to
steps:
  - id: loop
    repeat:
      max: 4
      until: "{{steps.count.result.0.n}} >= {{run.target}}"
      steps:
        - id: count
          sql: "select coalesce({{previous.count.result.0.n}}, 0) + 1 as n"
$y$);

select workflow.start_workflow('count_to', '{"target": 2}');

select t.step_key, t.status, t.output->'result'->0 as result
  from workflow.runs r
  join workflow.definitions d on d.id = r.workflow_def_id
  join workflow.tasks t on t.run_id = r.id
 where d.name = 'count_to'
 order by t.id;
one run of count_to
 step_key |  status   |                                        result
----------+-----------+--------------------------------------------------------------------------------------
 count_1  | succeeded | {"n": 1}
 count_2  | succeeded | {"n": 2}
 count_3  | skipped   |
 count_4  | skipped   |
 loop     | succeeded | {"last": {"count": [{"n": 2}]}, "attempts": 2, "satisfied": true, "max_attempts": 4}
Why it works — the compiler writes the attempts out, so a repeat is still a DAG

Attempt n of count is the ordinary step count_<n>. The first steps of an attempt wait for the last steps of the attempt before, and every step of an attempt after the first carries when: not (<until over the attempt before>). Once the check holds, the next attempt is skipped, and because a skipped step's result is null, so is every attempt after it. Each attempt is a task row a trace can read, and the bound is the number of rows written: max is required, from 1 to 20, and a repeat without it is refused.

Inside the group, {{steps.<inner>}} is this attempt's step and {{previous.<inner>}} the attempt before. On the first attempt there is none: in SQL (sql:, p8ql:, when:) the template is NULL, quotes around it included, which is why coalesce starts the count at 0; anywhere else, such as an agent's input, it is removed. until must name at least one of the attempt's steps, and an inner step's own when: still applies alongside it.

The repeat's id is a final step that waits for every attempt. The steps after the repeat read it rather than the attempts:

Template Value
{{steps.loop.result.0.attempts}} how many attempts ran
{{steps.loop.result.0.satisfied}} whether until held after the last of them
{{steps.loop.result.0.last.<inner>}} the last attempt's result for that step

An attempt can be any steps, agents included: a writer and a critic, with until over the critic's grade and the writer's input reading {{previous.<critic>.result}}, is the same document with two inner steps.

satisfied false with attempts equal to max is a repeat that ran out, and a step after it can say what to do then with a when: of its own. Repeats do not nest, the group takes only id, needs and repeat, and an attempt's name must not collide with another step's id.

Templates, and the one rule that bites

Four namespaces, all resolved at dispatch rather than at definition time.

Template Reads
{{run.<key>}} what start_workflow was called with
{{run.$id}}, {{run.$trace_id}}, {{run.$session}} the run's own identity — $ is reserved so an input key cannot shadow it
{{steps.<id>.result}} a completed step's output, dotted paths supported
{{item.<key>}} one matrix row, bound only on fan-out children
{{previous.<id>.result}} inside a repeat:, the attempt before's step; NULL in SQL on the first attempt
{{env.<VAR>}} the worker's environment. Never available to a sql step
Why it works — a whole-string reference keeps its type, an embedded one becomes text

This is the rule to internalise, because getting it backwards does not raise.

args: ['{{steps.embed.result}}'] is a whole-string reference, so it resolves to the vector as an array rather than to a string that looks like one. url: 'https://x/?since={{steps.cursor.result.since}}' is embedded, so it interpolates as text and the URL works.

The failure was in this collection's own headline example. A step passing 'SEARCH "{{run.question}}" FROM chunks LIMIT 3' is not a whole-string reference, so the substitution happened nowhere, the lexical half searched for the literal text {{run.question}}, every lexical rank came back null, and the fusion degraded to semantic-only, reporting nothing. It returned rows. It looked like it worked.

Typing {{run.id}} when you meant {{run.$id}} raises with the spelling you wanted, rather than resolving to nothing and failing three tasks later.

Getting this page from your own database

Here we read the vocabulary out of the installed compiler rather than out of a document.

select p8_workflow_grammar();               -- the document, step keys, actions
select workflow.compiler_capabilities();    -- and whether this build is current
select aiq.query('SCHEMA "workflow"');      -- plus what THIS deployment allows
Why it works — the vocabulary is fixed, but what a deployment accepts is rows

The step kinds are the engine's and they are in the parser. The functions a sql: step may call, the queues a step may route to and the agents an agent: step may name are all rows on the database in front of you, so no document can be authoritative about them.

SCHEMA "workflow" returns both halves and runs its own examples through the compiler on every read, so a spelling this deployment no longer accepts reports itself rather than being served as correct. compiler_capabilities() probes the installed build with one canary per feature and lists what is missing, which is how version skew between the Rust parser and the SQL schema shows up as a capability report rather than as a syntax error in a document that is not wrong.

workflow.compiler_capabilities()
 {"accepts": {"matrix": true, "output_schema": true, "continue_on": true,
              "signal": true, "timer": true, "sub_workflow": true,
              "when": true, "repeat": true},
  "missing": []}

Next: ten things, worked through, which puts this vocabulary to work one capability at a time; workflow recipes after it assembles it into whole pipelines.