Wet paintVera is under heavy development. Try the code, just expect things to move.
Vera

Workflows (early access)

Write your first workflow

Define steps, choose a journal, and resume completed work.

Workflows are in alpha. The API will change, so do not build on it yet.

This guide creates a small workflow, saves its results, and shows how to resume and inspect a run. Use Python 3.11 or newer from a Vera checkout. See How workflows run for how recorded steps work.

Run the included example

From the Vera checkout root:

mkdir -p /tmp/wf-demo
PYTHONPATH=python python3 examples/workflows/counter.workflow.py /tmp/wf-demo

The example prints a run ID and result, then stores the run under /tmp/wf-demo. The journal directory must exist before the run starts.

A workflow that calls a model and asks you

examples/workflows/writer.workflow.py researches a topic, outlines it, waits for your notes on the outline, then writes a draft. Each step makes one call to openai/gpt-5.6-luna through OpenRouter.

export OPENROUTER_API_KEY=...
PYTHONPATH=python python3 examples/workflows/writer.workflow.py "sea otters"

The run stops after the outline and prints its ID. Answer it:

PYTHONPATH=python python3 -m vera.workflow answer <run-id>

The command prints a local page address. The page shows the outline and takes your notes, then the run carries on and prints the draft. Pass the notes as a second argument to skip the page.

The run is stored under the Vera home, so /runs in the annex lists it with its steps, the model calls under them, and the charge OpenRouter reported for each call. Stop the script during a step and run python3 -m vera.workflow resume <run-id>: finished steps return their recorded replies and are not sent again. A call cut off by the stop records no usage, so its charge is missing from the total. examples/workflows/README.md walks through one stopped and resumed run.

--offline replaces the model with canned replies and needs no key. Offline calls record tokens but no cost.

Define steps and a workflow

A step is a function with @step. A workflow calls those steps from ordinary Python code:

from pathlib import Path
from vera.workflow.api import workflow, step

@step
def fetch(name: str) -> dict:
    return {"name": name, "size": 42}

@step
def summarize(doc: dict) -> str:
    return f"{doc['name']} is {doc['size']} bytes"

@workflow
def report(name: str) -> str:
    """Fetch one document and describe it."""
    return summarize(fetch(name))

run = report.run("notes.txt", journal_dir=Path("/tmp/wf-demo"))
print(run.id, run.status, run.result)

The step results are recorded. Work between steps is not. Put file writes, network calls, and other side effects inside steps, and make them safe to repeat if a run stops before recording the result.

Choose a journal

Pass exactly one of journal_dir or journal. For file storage, journal_dir points to an existing directory. For SQLite:

from vera.workflow.api import SqliteJournalStore

run = report.run("notes.txt", journal=SqliteJournalStore("/tmp/wf-demo.sqlite"))

Both are explicit storage choices. A journal is not automatically part of the resident host’s runtime or the TUI’s /work list.

Use serializable values

Step arguments and results use JSON-compatible values: dictionaries with string keys, lists, strings, numbers, booleans, and None. Unsupported arguments, such as bytes or custom objects, fail serialization before the body runs.

Resume a run

Call the workflow’s resume method with its saved run ID and journal:

run = report.resume(run.id, journal_dir=Path("/tmp/wf-demo"))

Completed steps return recorded values. A failed, suspended, or interrupted step is tried again if it has no successful record.

Resume from another process

For a workflow with a recorded source entry:

PYTHONPATH=python python3 -m vera.workflow resume <run-id> --journal-dir /tmp/wf-demo

The command loads the recorded source file and restores the original working directory and inputs. Use the database path instead of the directory for a SQLite journal.

A run that is still running is refused with status 2: resuming it twice would run the step in flight in two processes at once. If its process is gone from this machine the command goes ahead, and if the run was left by another machine it says to sweep there, since only that machine can tell whether the process is still alive.

An older journal or a run created through python -c may have no source entry. The command then exits with status 2 and directs you to call the workflow’s resume method in its original context.

Read the outcome

run.statusMeaning
okThe workflow returned a result.
failedA step or hook failed. Read run.error.kind and run.error.message.
suspendedThe workflow paused for input, such as an ask.
crashedThe process running it died. A sweep found it and said so.
runningThe run is active.

A failed step returns a Run result rather than raising out of run(). Steps are retried, not rolled back.

Ask for an answer

ask stops the run until someone answers a question, then returns the answer as a string:

from vera.workflow import ask, step, workflow

@workflow
def publish(topic: str) -> str:
    text = draft(topic)
    verdict = ask(f"Ship this?\n\n{text}")
    return f"{verdict}: {text}"

The first run ends suspended, with the question in the header’s asking field. show prints it. To answer:

PYTHONPATH=python python3 -m vera.workflow answer <run-id> "ship it" --journal-dir /tmp/wf-demo

The answer is stored in the run’s inbox and the run resumes. Completed steps return their recorded values, so draft does not run again.

Without the text, answer prints a page address on 127.0.0.1 and waits. The page shows the question and takes one answer, then closes, and the run resumes in the terminal. The address carries a random path, and any other path returns 404. Ctrl-C leaves the run waiting.

Each ask gets a key: ask#0, ask#1, and so on in the workflow body, or <step key>/ask#0 inside a step. A resumed run asks the same questions in the same order, so each one reads its own answer. A step retried after a failure asks its questions again and reads the same answers.

ask outside a running workflow raises WorkflowError.

Inspect recorded progress

PYTHONPATH=python python3 -m vera.workflow show <run-id> --journal-dir /tmp/wf-demo

The output includes the run ID, workflow name, status, and completed step keys with how long each step took. While a step is running, and after a crash, the report also names the step in flight. It then lists one line per attempt: the process that ran the workflow, when it started, and how it ended. An attempt with no ending is one whose process never came back. A workflow docstring also appears in the report.

If the ID is missing, the error lists up to ten available run IDs and counts any remaining ones. In file storage, run IDs are also the directory names.

Spans

Under each attempt the report lists its spans. A span is one try of one step, with the step name, how long the try took, and how it ended (ok, failed, suspended, or cancelled). A failure carries its message.

Spans and journal records answer different questions. The journal records a step once, when it succeeds, because that is the value a resume replays. Spans record every try, so a step that failed twice before succeeding is three spans and one record. A span with no ending is the try whose process died in it.

Field names follow OpenTelemetry, and the span kind follows OpenInference, so an exporter maps ids rather than shapes. What a workflow records that OpenTelemetry has no word for rides in attributes under a halcyon. prefix.

for span in store.load(run_id, None).spans():
    attributes = span["attributes"]
    print(
        attributes["halcyon.attempt"],
        span["name"],
        attributes.get("halcyon.outcome"),
    )

A start line and an end line share a span_id:

{"trace_id": "wf_1a2b3c4d5e6f7a8b", "span_id": "a1.0", "parent_id": "a1",
 "name": "fetch", "start_time": "2026-09-17T12:00:00+00:00",
 "attributes": {"openinference.span.kind": "CHAIN", "halcyon.attempt": 1,
                "halcyon.step.key": "demo/fetch#0:97fcdad9"}}
{"span_id": "a1.0", "end_time": "2026-09-17T12:00:00.151+00:00",
 "status_code": "OK",
 "attributes": {"halcyon.outcome": "ok", "halcyon.duration_ms": 151}}

The trace is the run and the parent is the attempt, so a1 is the first .run() and a2 the resume after it.

Record what a model call used

A step that calls a model wraps the call, and the tokens it reports become a span of their own under the step:

from vera.workflow.api import model_call, step

@step
def ask(prompt: str) -> str:
    with model_call("claude-opus-5", provider="anthropic") as call:
        call.input(prompt)
        answer = client.messages.create(...)
        call.output(answer.content[0].text)
        call.usage(
            input_tokens=answer.usage.input_tokens,
            output_tokens=answer.usage.output_tokens,
            cost=0.0123,
        )
    return answer.content[0].text

cached_input_tokens and cache_write_input_tokens are optional too. They are parts of input_tokens, not additions to it, so a provider that reports cache reads and writes separately from input needs them added in.

cost is optional. Vera records the number it is given and adds nothing of its own, so a run with no reported cost shows tokens and no money. The report prints the total:

cost   $0.0127
attempts
  1  2026-09-17T12:00:00+00:00  host pid 4120  ok
       ask  1ms  ok
         claude-opus-5  0ms  ok  1510 tokens  $0.0123

The counts ride in attributes under OpenInference names (llm.model_name, llm.token_count.prompt, llm.token_count.completion, llm.token_count.total, llm.token_count.prompt_details.cache_read, llm.token_count.prompt_details.cache_write, and llm.cost.total), on a span whose kind is LLM.

input() and output() record what the call sent and got back. Each takes a string or any JSON value, such as a list of messages. Both are optional. A value up to 8 KB is stored on the span as input.value or output.value; a larger one is stored as a blob in the run and the span holds its digest. The runs page shows both when you open the call.

Calling model_call outside a step runs the block and records nothing, so a helper that uses it works whether or not a workflow called it. A second usage() on the same call replaces the first.

Runs stored under the Vera home also count toward the totals on /usage. A call without cost is estimated from current OpenRouter rates when its provider is openrouter, and is otherwise left unpriced.

Find runs whose process died

A run left running by a process that never came back stays running until someone looks. The sweep does the looking:

PYTHONPATH=python python3 -m vera.workflow sweep --journal-dir /tmp/wf-demo

It considers a run only when the last attempt is still open and its pid on this host is gone. Those runs are marked crashed with a reason, and the open attempt is left open as the evidence. Nothing is run again.

Passing --resume resumes every crashed run in the store, in this process, one after the other, whether this sweep marked it or an earlier one did. A run someone asked to cancel is reported and left alone.

The sweep reads pids on the machine that recorded them, so it skips runs whose attempt names another host, and it skips a pid the operating system has since handed to something else. Run it on the machine the workflows ran on.

Journal files

/tmp/wf-demo/<run-id>/
  header.json
  journal.ndjson
  spans.ndjson
  blobs/

The header stores run metadata and inputs, and while a step is running it also names that step under active. attempts holds one entry per run or resume, each with started_at, pid, and host, and gaining finished_at, status, and, on a failure, error when that attempt ends. The journal has one record per successful step, each with the time it finished (at) and how many milliseconds it took (ms). Values larger than 8192 bytes are stored in blobs/ with a hash reference. spans.ndjson has two lines per span, one opening it and one settling it, folded together when read. SQLite stores the same facts in runs, records, spans, and blobs tables in one database file.

Read a file journal from TypeScript

import { readWorkflowRun } from "./src/sdk/workflow-journal.ts";

const run = readWorkflowRun("/tmp/wf-demo/<run-id>");
console.log(run.header.workflow, run.records.length);

This reader supports file journals, not SQLite databases.

Try an interrupted run

The included audit example measures tracked TypeScript and Python files in a checkout. From the Vera checkout root:

mkdir -p /tmp/wf-audit
PYTHONPATH=python python3 examples/workflows/audit.workflow.py run /tmp/wf-audit /path/to/vera

To stop after a chosen number of files and then resume:

mkdir -p /tmp/wf-crash
PYTHONPATH=python python3 examples/workflows/audit.workflow.py run /tmp/wf-crash /path/to/vera 400
PYTHONPATH=python python3 examples/workflows/audit.workflow.py resume /tmp/wf-crash <run-id>

The example also appends each measurement to measured.log. A file measured just before interruption may appear twice if its journal record was not saved. This demonstrates why steps with side effects must tolerate retries.

Continue with step controls or the Python instance API.