All Stories
2026-11-03•17 min read

Agent State Machine · TableModel · Computed Columns · MCP · Pixeltable Cloud · Workflow

How to build an agent state machine in Pixeltable

The model proposes a transition. A Pixeltable table decides whether it is allowed. This post builds a task queue with guarded updates, computed-column evidence, version history, and HTTP or MCP tools any coding agent can call.

Pierre Brunelle

Pierre Brunelle

Pixeltable Team

View as Markdown

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#

bash

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.

python

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.

python

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.

python

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.

python

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#

python

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?.

python

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().

python

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.

python

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.

Frequently asked questions

What is an agent state machine?

An agent state machine is software that owns operational rules an LLM would otherwise keep in a prompt: which action is allowed now, when a human must approve, and when the work is done. The model proposes a transition. Code accepts or refuses it. The current state lives in a table, not in the conversation.

How do you add human-in-the-loop approval to an AI agent workflow?

Store each unit of work as a row. The agent writes a draft and asks to move the row to awaiting_approval. A computed column scores that draft. Software moves the row only when the score passes. A reviewer calls approve or reject. Reject sets the row back to drafting with a note. The agent never writes the state column on its own.

Does Pixeltable have compare-and-set or row locking for a task queue?

Pixeltable has no user-facing compare-and-set API. The guard is an update whose where clause names the id and the expected state, then a check of UpdateStatus.row_count_stats.upd_rows. Default tables are data-versioned, so a data write takes an exclusive table lock and writers serialize. A lost claim returns no row. Nested JSON is not SQL-filterable; store a bool column for the gate. A UUID filter needs a uuid.UUID, not a string.

When should I use Temporal, Restate, DBOS, or LangGraph instead of Pixeltable?

Use Temporal, Restate, or DBOS when the problem is durable execution of the agent loop: retries, timers, child workflows, and crash recovery of an in-flight tool call. Use LangGraph when the work is a checkpointed graph of LLM nodes. Pixeltable is the state and data layer for the work itself. It is not a durable-execution engine. Keep Pixeltable next to those tools when the agents also write media, embeddings, or evals.

How do Claude Code and Codex share one task queue?

Put the transitions in Python on a Pixeltable table, then expose claim_task, request_approval, and complete_task over HTTP with pxt service update, or wrap the same functions with an MCP server. Each coding agent calls the tools. Neither owns the state file.

Declarative. Multimodal. Incremental.

One Python file for tables, indexes, and HTTP. Start locally, or sign up for Cloud.

Get started

Docs quickstart · 10-minute tutorial · GitHub · Discord