Summary: You will have a tasks table that is the state machine of the work, Python functions that refuse illegal transitions, a computed column that scores a draft before approval can succeed, and HTTP routes (or MCP tools) that Claude Code, Codex, or a custom agent can call. I ran every listing on Pixeltable 0.7.23 and mcp 2.3.0 in a Linux container. The judge variant ran with a stub in place of the model, and I did not call a model. The model never writes state on its own.
Josh Rosen's article Agent State Machines: Building Deterministic Scaffolding for Agents (3 November 2026) puts the same split in one sentence. The model proposes a transition. Software decides whether it is allowed. Approval before action, recovery from a failed tool call, and validation before completion are rules. They belong in code, not in a prompt.
Rosen names four places a state machine shows up: inside one agent loop, as a workflow graph, as a supervisor of many runs, and as the work itself (his ThruWire product: checkpoints with requirements, evidence, and dependencies). This post is the fourth. If you want a DAG of LLM nodes, that is The Pipeline Is the Table and the agentic workflows docs. If you want durable execution of the loop, skip to the last section and use Temporal, Restate, DBOS, or LangGraph. Pixeltable is the wrong tool for that job.
Create the project#
Python 3.11 or later. pxt init marks the project root. pxt schema update creates the tables. pxt service update starts HTTP. Pass -f when a script or a coding agent runs these, because without a terminal they refuse. The service picks its own port. Read the endpoint from pxt service list --json. My service answered at http://127.0.0.1:38611.
The work is a table#
One row is one unit of work. The columns are the machine: who owns it, the draft, the reviewer's note, and two computed columns that score that draft.
A new row starts queued, with evidence.ok false because there is no draft yet. That is ThruWire's idea on one table: the work does not complete without evidence. A computed column runs when its inputs change. You do not call the judge from the agent loop.
Guard every transition#
Each event is a function. The function names the states that may accept it, writes the destination, and returns the updated row or None. The model asks. The where clause answers.
Two details that are not obvious. A filter on the UUID primary key must use a uuid.UUID. A string raises "Filter not expressible in SQL". And you cannot write .limit(1).update(). Query.update uses the where clause only. Pick one id, then update that id while it is still queued.
I enqueued "Release notes", then posted /claim as worker-a and again for the same id as worker-b. The first answered 200 and the second 409. Two threads posting /claim for one queued id: racer-0 got 200, racer-1 got 409.
There is no compare-and-set API#
Pixeltable does not expose compare-and-set. The pattern that ran is: update(..., where=..., return_rows=True) and then status.row_count_stats.upd_rows. Zero means the event was illegal or someone else got there first.
Default tables are data-versioned. A data write takes an exclusive table lock, so writers on that table serialize. The second claimer waits, then its where clause sees claimed and matches nothing. That is stronger isolation than two overlapping row updates. It is also a throughput limit: an update of a different row waited for the first writer to finish (the measurement is under the judge listing), so many agents claiming different rows take the table one at a time. If you need a high-rate queue, this table is the wrong tool. Use a durable-execution engine, or a queue product, and keep Pixeltable for the artifacts the agents write.
One limit of the code below: request_approval is two updates, one for the draft and one for the state. I paused one submission between its two updates, submitted a second draft, and had a reviewer reject it. When the first submission resumed, the row went back to awaiting_approval with the rejected draft and the reviewer's note still on it, and nobody had resubmitted. The evidence gate still holds, since passed is read from the stored draft. Keep one submitter per task, or serialize submissions in the caller.
Done requires evidence#
request_approval writes the draft first. That recomputes evidence and passed. A second update moves the row to awaiting_approval only when passed is true. Nested JSON such as evidence['ok'] raises "Filter not expressible in SQL" in the where of an update. That is why passed is a stored bool.
The draft "too short" stayed in drafting, and the route answered 409 with evidence failed: {'ok': False, 'reason': 'draft shorter than 40 characters'}. A longer draft that named the title moved to awaiting_approval with passed: true.
The run above uses a rule UDF. The column contract does not change if you swap in the LLM-as-judge pattern. I had no OpenAI key, so I ran this variant with a stub UDF in place of openai.chat_completions that returns the shape of the OpenAI response. The stub is a test harness and is not in the listing. No model was called.
judge_messages returns None for a task with no draft, which skips the model call, so a queued task has a null evidence and a null passed. Building the prompt with pxtf.string.format(..., draft=draft) instead raised KeyError: 'draft' on 0.7.23 when draft was null, which is every new task. With the stub, a draft it accepted gave {'ok': True, ...} and passed true, and a draft it refused gave passed false. Keep passed = evidence_passed(evidence) as the gate. Anthropic is pixeltable.functions.anthropic.messages, which requires max_tokens, with the same messages.
The table lock covers the judge call. The stub sleeps 4 seconds where a model would take its latency. One thread updated a draft on it, and an update of a different row started one second later. The second update returned after 3.0 seconds, when the first finished. Check that before you put a slow judge on a busy table. I measured this in one process with two threads, not against a model.
Reject returns the row to drafting#
That is Rosen's loop from drafting to approval to sending, with rejection as an edge back to drafting. I rejected "Release notes" with the note "Name the FastAPIRouter change in the first paragraph." The row's state became drafting, owner stayed worker-a, and a resubmit went to awaiting_approval again. Approve, then complete, left it done.
approve, reject and fail_task take no caller identity, and this app does not authenticate callers. An agent that can call /claim can also call /approve: in my run, one caller claimed a task, drafted it, approved it and completed it. Put approval behind a separate credential, or add a reviewer argument and a Tasks.owner != reviewer predicate. History records each state and review_note, not who called.
History is the audit trail#
Every insert and update is a table version. history() is the human report. get_versions() is the dict form. Time travel is pxt.get_table('agent_sm.tasks:N'), read-only. The docs are Version control and lineage and the glossary page What is time travel?.
After a crash, the same agent_id asks /inbox and continues its own rows. start_draft, request_approval and complete_task require owner == agent_id, so another agent cannot take a dead claimant's row over, and no transition returns a row to queued. A release transition would be a new edge. In my run, crashy claimed a task, started drafting and "died". /inbox for crashy listed the row in drafting, /inbox for other did not, and other got 409 on /start-draft and on /request-approval. crashy then submitted and the row reached awaiting_approval. Nothing in Pixeltable restarts the agent process for you. Agent memory in Pixeltable is the same idea for conversation state. Time travel is the longer versioning write-up.
Serve claim, approve, and complete over HTTP#
FastAPIRouter.add_update_route updates a row by primary key. A guarded transition needs extra predicates, so the serving docs tell you to use a plain FastAPI @app.post() when a helper cannot express the request. Queries still go through add_query_route. Put both on one app with include_router().
request_approval returns None for another agent's task or a task that is not drafting, and the written row, still in drafting, when the check fails. The route turns the second into a 409 that carries evidence. After a full HTTP pass, the OpenAPI paths were /approvals, /approve, /claim, /complete, /enqueue, /fail, /inbox, /open, /reject, /request-approval, /start-draft. The how-to for typed clients is How to serve a typed AI endpoint and the glossary page What is HTTP serving?.
Same functions as MCP tools#
Pixeltable 0.7.23 consumes MCP servers (pxt.mcp_udfs) so a computed column can call someone else's tools. It does not publish yours. Rosen describes an MCP server with tools such as claim_task and complete_task. That server is a thin wrapper around the Python you already have. Install it with pip install mcp (2.x, which renamed FastMCP to MCPServer) and save it as mcp_server.py next to app.py. It runs in its own process, so it binds the models itself. I called all five tools in-process with MCPServer.call_tool. A short draft left the row in drafting. A good draft moved it to awaiting_approval, and approve and complete left it done with owner mcp-agent. I did not run mcp.run(transport='stdio') or connect Claude Code or Codex to it.
Point Claude Code or Codex at that stdio server, or keep calling the HTTP service. Either way the table is the shared machine. Agents that call other agents as tools, rather than sharing this queue, are a different pattern: Pixelagent team workflows. What is MCP? is the glossary page. Pixeltable Cloud's hosted MCP at /mcp/cloud is a different product. It operates a signed-in organization. It is not this task queue.
When to use Temporal, Restate, DBOS, or LangGraph#
Rosen's tradeoff is the right one. A homegrown machine is a handful of states, some transition functions, and a polling loop. You keep the coding agent you already run. You keep the database you already have. You can also end up rebuilding checkpointing, locking, scheduling, observability, and crash recovery of the loop.
- Temporal, Restate, and DBOS are durable-execution engines. Use them when a tool call must resume after the process dies, when you need timers and child workflows, or when the unit of recovery is a step in the agent loop.
- LangGraph is a checkpointed graph of LLM nodes. Use it when the workflow is that graph.
- n8n, Mastra, and similar workflow runners are fine when the job is SaaS glue. The comparison against those for media pipelines is The Pipeline Is the Table. Airflow is the job-DAG cousin: Pixeltable vs Airflow.
Pixeltable is the state and the data. Media, embeddings, and evals sit on the same rows as state. That is the storage / orchestration / retrieval split, and the same "compute close to data" argument as pg_durable, one layer up from SQL. It will not retry a hung HTTP call to a tool, and it will not schedule a timeout. Pair it with a durable-execution engine when you need both.
The same file on Pixeltable Cloud#
Once pxt db status pxt://org:main shows AVAILABLE, run pxt db diff pxt://org:main --add-config-entry, then pxt db update pxt://org:main -f, pxt schema update app.py pxt://org:main -f, and pxt service update app.py pxt://org:main -f. pxt db update errors until the [[pixeltable.database]] entry exists. I ran none of the hosted steps.The walkthrough is Deploy to Pixeltable Cloud. pxt new makes a free hosted database with no account. Configuration of that database is in Configure Pixeltable Cloud. The local-to-cloud loop for one app.py is What is Pixeltable?.
Next: put the Tasks model, the transition functions and the HTTP routes in app.py, run the commands at the top, and call POST /claim. Run the history snippet in a REPL after pxt schema update, and keep the judge variant and mcp_server.py out of app.py. Or hand a coding agent get-started.md and this post.


