GitHub - CyrusNuevoDia/gpt-workflow: Deterministic multi-agent workflows

7 min read Original article ↗

A deterministic multi-agent workflow runtime powered by Codex App Server. Control flow — loops, branches, retries — is plain JavaScript in your script; agent() delegates bounded judgment to Codex threads, and parallel(), pipeline(), and child workflow() calls fan that work out.

Compared to driving Codex directly, a workflow adds:

  • Resumability — long or token-expensive runs replay completed calls from a durable journal instead of paying for them again.
  • Validated structured output — pass a JSON schema to agent() and the runtime validates the reply and retries invalid ones, instead of you policing format in prompt text.
  • Multi-agent verification — independent critics and judge panels, where a failed call resolves to null instead of aborting the fan-out.

How did I use Codex to build gpt-workflow?

Install

Everything requires Bun 1.3 or newer, with the Codex CLI installed and authenticated for live runs. Node.js is not a supported runtime.

The preferred install is the Codex plugin. It bundles a skill that authors, runs, and debugs workflows for you, and it runs the published CLI through bunx, so there is no separate package to install:

codex plugin marketplace add CyrusNuevoDia/gpt-workflow
codex plugin add gpt-workflow@gpt-workflow

Restart the ChatGPT desktop app after installing and start a new task so the bundled skill loads; see plugin installation and behavior.

To drive the CLI yourself, install globally:

bun add --global gpt-workflow

For library use:

Write a workflow

Store project workflows under .codex/workflows/; this example is summarize-files.js:

export const meta = {
  name: "summarize-files",
  description: "Summarize files concurrently and merge the findings",
};

const files = args.files;
const summaries = await parallel(
  files.map(
    (file) => () =>
      agent(`Read ${file} and return three factual bullets.`, {
        label: `summarize:${file}`,
      })
  )
);

return { summaries: summaries.filter(Boolean) };

args is the run's input; the --args flag in the next section supplies it. meta.name is also the run-storage directory name, so it may contain only letters, numbers, periods, underscores, and hyphens, and cannot be . or ...

If an agent's thread ends in an error, times out, returns no final message, or exhausts its structured-output retries, that call resolves to null and is recorded in the run's failures — the filter(Boolean) drops those slots. Script bugs, setup problems such as missing models or bad option types, cancellation, worktree-setup failures, and transport failures reject the whole run instead.

Workflow source is trusted repository code; it runs inside node:vm as a semantic boundary, not a security sandbox for hostile JavaScript.

Run and resume

A live run spends model tokens. Resume replays completed calls from the journal — their tokens are not spent again — then runs the rest live:

gpt-workflow models
gpt-workflow run --default-model gpt-5.6-luna \
  --args '{"files":["src/cli.ts","src/runtime.ts"]}' \
  .codex/workflows/summarize-files.js
gpt-workflow run --default-model gpt-5.6-luna --resume workflow-123 \
  --args '{"files":["src/cli.ts","src/runtime.ts"]}' \
  .codex/workflows/summarize-files.js

--default-model supplies the model for agent() calls that omit options.model; without either, the run rejects with a model error. --args takes strict JSON and becomes the script's args global; pass a plain string as quoted JSON, e.g. --args '"triage"'. Invalid JSON exits 1 with a usage error on stderr before any record is emitted. For --resume, substitute the runId reported by your original run's records — real IDs look like workflow-<uuid>; these examples shorten it to workflow-123. Resume with the same --args: changed args change prompts, which miss the journal and run live. Resume is strict: a missing run ID, duplicate run ID, or run stored under a different workflow name exits before connecting to Codex.

models prints every model discovered from the authenticated App Server as NDJSON without spending model tokens. Run accepts repeatable --required-model flags plus --request-timeout-ms, --thread-start-timeout-ms, and --turn-timeout-ms. SIGINT or SIGTERM cancels active agents, records a terminal failure, flushes persisted events, and exits nonzero.

Stdout is ordered NDJSON; human diagnostics go to stderr. Every record includes schemaVersion, sequence, runId, scriptPath, runDirectory, ts (epoch milliseconds), and type. The opening run.started record carries the script's meta; the final run.completed record carries meta, result, usage, failures, and journalPath:

{"meta":{"name":"summarize-files","description":"Summarize files concurrently and merge the findings"},"type":"run.started","runDirectory":"/home/me/.codex/projects/-repo/workflows/summarize-files/runs/workflow-123","runId":"workflow-123","schemaVersion":1,"scriptPath":"/repo/.codex/workflows/summarize-files.js","sequence":0,"ts":1783971328984}
{"failures":[],"journalPath":"/home/me/.codex/projects/-repo/workflows/summarize-files/runs/workflow-123/journal.jsonl","meta":{"name":"summarize-files","description":"Summarize files concurrently and merge the findings"},"result":{"summaries":["",""]},"type":"run.completed","usage":{"agentCount":2,"liveAgentCount":2,"modelUsage":{"gpt-5.6-luna":{"liveAgentCount":2,"replayedAgentCount":0,"subagentTokens":3412}},"peakConcurrentAgents":2,"replayedAgentCount":0,"subagentTokens":3412},"runDirectory":"/home/me/.codex/projects/-repo/workflows/summarize-files/runs/workflow-123","runId":"workflow-123","schemaVersion":1,"scriptPath":"/repo/.codex/workflows/summarize-files.js","sequence":9,"ts":1783971339402}

After valid metadata creates the run directory, a top-level failure makes the CLI emit run.failed and exit non-zero. Source-read and metadata-parse failures write only to stderr and create no run artifact. Agent-side null failures don't fail the run: they stay visible in the run.completed record's failures and are retried on resume.

Inspect past runs

There is no need to tee the stream for later inspection: every run also persists a filtered copy of its NDJSON to $CODEX_HOME/projects/<encoded-project-path>/workflows/<workflow-name>/runs/<runId>/events.jsonl — the run, phase, and agent status records needed to rebuild run state, without the high-volume streaming deltas. Two commands read it back without spending model tokens:

gpt-workflow list
gpt-workflow status workflow-123

list prints one JSON line per run, newest first, with runId, name, scriptPath, status, timestamps, and — once the run ended — finishedAt, failureCount, and usage. status prints one JSON object that adds ordered phases, per-agent progress and token totals, and the final result and failures. Output is plain JSON, so jq applies:

gpt-workflow status workflow-123 | jq '.phases'

A run with no terminal record reports "incomplete": an in-flight and an interrupted run look identical on disk, so the CLI never claims "running" — check lastEventAt for staleness. An unknown run ID makes status exit 1 with an error on stderr.

Durable journals

Live runs persist an append-only replay journal at:

$CODEX_HOME/projects/<encoded-project-path>/workflows/<workflow-name>/runs/<runId>/journal.jsonl

CODEX_HOME defaults to ~/.codex. The project key is the absolute invocation directory with path separators replaced by dashes, including the leading root separator: /repo becomes -repo. Run state is local user data and does not need a repository ignore rule.

The same directory holds events.jsonl, the inspection copy described above; the journal remains the only replay substrate.

Resume reuses that runId and directory. Completed agent() calls are matched by their prompt and options, regardless of the order they finished in; at the first call with no journal match, that call and every later call runs live and appends to the same journal.

To inspect a journal, parse it one record at a time with parseWorkflowJournalEntryGetting started shows a streaming loop. The parser throws a SyntaxError on blank text, malformed JSON, or records that are not valid journal entries; it never returns null, so wrap each parse in try/catch when surveying a damaged journal.

The journal is workflow replay state. Codex separately persists full agent thread rollouts and exposes them through App Server thread APIs; their private on-disk layout is not a gpt-workflow contract.

Library API

import {
  AppServerClient,
  REQUIRED_APP_SERVER_MODELS,
  runWorkflowScript,
} from "gpt-workflow";

const source = `
export const meta = {
  name: "summarize",
  description: "Summarize a topic"
}

return await agent("Summarize " + args.topic)
`;

const client = await AppServerClient.connect({
  defaultModel: "gpt-5.6-luna",
  requiredModels: REQUIRED_APP_SERVER_MODELS,
});

try {
  const execution = await runWorkflowScript(source, {
    appServer: client,
    args: { topic: "deterministic orchestration" },
  });
  console.log(execution.result, execution.journalPath);
} finally {
  await client.close();
}

Running this example spends model tokens; inject agent to drive offline tests without Codex. REQUIRED_APP_SERVER_MODELS is the model set the runtime depends on — connect rejects when the App Server cannot start or is missing any of them. runWorkflowScript accepts runDirectory for caller-owned storage and resumeFromRunId for library resume, and splits failures exactly as Write a workflow describes: agent-side failures resolve to null and land in execution.failures; everything else rejects. listRunSummaries and readRunStatus expose the list and status data programmatically; see the API reference.

Documentation

Start with Getting started or the full documentation index. Structured output schemas, budgets, agent options, and child workflows are covered in the API reference; verification and fan-out shapes in Patterns.

Migrating Claude Code workflows? See the Claude parity ledger and the migration checklist.

Working on this repository itself? See AGENTS.md.