ChopFlow is a durable distributed task queue with a Rust-fast core, multi-language client libraries, and an embedded live operations dashboard — all from a single binary.
ChopFlow enqueues, dispatches, and observes background work across a fleet of workers. It belongs to the Celery / Ray / Dask lineage of task queues — not to the workflow-engine family like Temporal or Airflow. ChopFlow does not orchestrate multi-step DAGs or sagas; it reliably executes individual units of background work with retries, dead-lettering, and live observability.
The broker is written in Rust and ships as one binary that embeds the dashboard. Workers pull work from the broker (pull model — backpressure is implicit). Client libraries let you define and enqueue tasks from Rust, Python, or Java.
Durability first. Tasks are persisted before they are eligible for dispatch. A broker restart reconciles in-flight work.
Pull-based dispatch. Workers fetch work from the broker; the broker never pushes. Backpressure is implicit.
Explicit state. Every task moves through a visible lifecycle. There are no hidden transitions.
One binary. The broker embeds the dashboard — no separate frontend deployment to operate.
Not a workflow engine
ChopFlow executes individual tasks. If you need multi-step DAG orchestration, sagas, or long-running workflow state machines, use Temporal or Airflow alongside ChopFlow.
Quickstart
Install ChopFlow, start a broker, register a worker, and enqueue your first task — in under two minutes.
Install once, use everywhere
This is the only page that covers installation. Every other page assumes the unified chopflow command (and, for workers, chopflow_worker) is already on your PATH.
One chopflow binary covers the broker, CLI, and MCP server. Verify with chopflow --help, then continue below. (Workers ship separately: cargo install chopflow_worker.)
The unified chopflow binary lands in target/release/ (alongside chopflow_worker). Add that directory to your PATH (or reference it directly) so the commands below work as written. See Installation for the full binary list and prerequisites.
Define a task payload in task.json and enqueue it via the CLI. The broker assigns an ID and makes it eligible for dispatch.
terminal
$ ./target/release/chopflow enqueue \
-f task.json --name training --tags gpu,mlEnqueued: training · 9f4a2c (attempt: 0, eta: now)
That's it
The task is now in the queue. Open http://localhost:8080 to watch it flow through the lifecycle in the dashboard.
Installation
Build ChopFlow from source using Rust and Cargo. The whole workspace compiles to a single set of binaries — no Node toolchain is required for a fresh clone.
The broker persists tasks with SQLite by default (a chopflow.db file in the working directory). Pass --storage memory for an ephemeral, in-memory backend useful for tests and demos. See Storage Backends.
No Node required to run
The dashboard's built bundle (broker/ui/dist/) is committed and embedded via rust-embed. You only need Node/pnpm if you edit the UI source.
Task Lifecycle
Every task moves through an explicit lifecycle from submission to terminal state. There are no hidden transitions — each state is persisted and visible in the dashboard.
The TaskStatus field on a task encodes its entire lifecycle. The broker never pushes work — workers call FetchTasks and the broker atomically transitions matching Queued tasks to Running. This pull model means backpressure is implicit: if no worker fetches, work simply waits.
On failure, a task with remaining retries transitions back to Queued with an incremented retry count and a computed ETA (see Retry & Dead-Letter). When retries are exhausted, it moves to DeadLettered.
Restart reconcile
On broker startup, any task left Running from a previous run is reset to Queued — its worker is gone after a restart, so the work is requeued rather than lost.
A standard 5-field cron expression (e.g. */2 * * * *, 0 9 * * *). The broker normalizes it to the 6-field form the cron crate expects. Fires on every match.
Each schedule carries an overlap policy that governs what happens when a fire is due but a previous run for the same schedule is still in flight (Queued or Running):
Policy
Behavior
skip
Skip this fire and advance next_fire. Default.
coalesce
Treated like skip for now; reserved for future merging behavior.
allow
Materialize the task anyway — concurrent runs permitted.
The server evaluates cron and ETAs in UTC. The dashboard converts from the local picker before sending.
Missed cron runs are not backfilled. On broker startup, enabled cron schedules have their next_fire recomputed from now, so the ticker only fires future matches. A disabled one-shot with a past ETA stays disabled until re-enabled.
Every schedule carries a TaskTemplate — the name, payload, tags, resources, and max_retries that each materialized task inherits. The dashboard's "Run now" action enqueues a one-off task from the template without disturbing the schedule's overlap accounting.
next_retry_time(retry_count) computes when a failed task becomes eligible again. It returns None when the retry count exceeds max_retries — at that point the task transitions to DeadLettered and is available for inspection and replay.
The computed ETA is stored on the task, so a retried task simply sits in Queued until its ETA arrives and a matching worker fetches it.
Dead-letter is terminal
DeadLettered tasks will not retry further on their own. Replay them explicitly from the dashboard or the API.
Priority & Ordering
Every task carries an integer priority. The broker dispatches the highest-priority ready tasks first, so urgent work jumps ahead of a backlog of batch jobs. The default priority is 0, which preserves FIFO ordering.
Task.priority is a signed 32-bit integer (core/src/task.rs). Higher numbers are claimed before lower ones. The default is 0; negative values are allowed and dispatch last. Priority is set at enqueue time and rides along on a schedule's TaskTemplate.
core/src/task.rs
let task = Task::new("resize_image", payload, tags)
.with_priority(5);
When a worker fetches tasks, the broker's claim_ready selects ready tasks (matching tags, ETA elapsed, resources satisfied) and orders them by:
Tiebreaker
Direction
Meaning
priority
DESC
Higher priority claimed first.
eta
ASC
Within a tier, no-ETA (run ASAP) before scheduled.
enqueue_time
ASC
Within a tier and ETA, oldest first (FIFO).
id
ASC
Final deterministic tiebreaker (SQLite).
The same ordering applies to both storage backends. The in-memory backend sorts a candidate scan; SQLite uses ORDER BY priority DESC, eta_ms, enqueue_ms, id backed by a tasks_priority index on (status, priority).
Priority is accepted everywhere a task is enqueued — the CLI (--priority), the HTTP POST /api/tasks body, the gRPC EnqueueTask request, the MCP enqueue_task tool, and the Java/Python client builders. Omitting it defaults to 0.
FIFO by default
Because the default priority is 0, a fleet of tasks enqueued without a priority still dispatches in enqueue order — priority is strictly opt-in.
Existing SQLite databases gain a priority INTEGER NOT NULL DEFAULT 0 column via an idempotent ALTER TABLE on broker startup; old rows default to priority 0. No manual migration step is required.
Resources & Tags
Tags route tasks to matching workers. Resources declare per-worker capacity for CPU, GPU, and memory — the dispatcher only hands a task to a worker that can satisfy it.
A task carries a list of tags (e.g. gpu, ml). A worker registers with the tags it can handle. The broker only returns a task to a worker via FetchTasks when the worker's tags match the task's tags. This is how you keep GPU work on GPU workers and CI work on CI workers.
A task declares the resources it needs as a map of resource name to amount (e.g. {"gpu": 1, "cpu": 4}). The ResourceRequirements type (core/src/resources.rs) is the task-side half of the contract.
A worker declares ResourceAvailability with two maps: total (capacity) and available (currently free). When a worker claims a task, the broker decrements available; when the task is acked, it is returned. This prevents overcommitment — a worker never receives more work than it can run.
When a worker calls FetchTasks, the broker matches queued, ETA-eligible tasks against that worker:
The task's tags must be a subset of the worker's tags.
The task's ResourceRequirements must be satisfiable by the worker's current ResourceAvailability.
The task's ETA must have arrived.
Matching tasks are atomically transitioned Queued → Running and handed to the worker, up to max_tasks.
Checkpointed Pipelines
A task may declare its pipeline stages. A context-aware handler persists a checkpoint per stage, and a retried task resumes from the last completed stage — Temporal-style durable execution without a workflow engine.
Stages are declared at enqueue time. The CLI takes a comma-separated list; the HTTP body, the gRPC EnqueueTaskRequest, and the MCP enqueue_task tool all accept the same stages field.
The stages may also live as a top-level "stages" array in the task JSON file — the --stages flag wins when both are present. Stage names are free-form; declaring them makes the pipeline visible to the dashboard and gives the worker the ordered list to checkpoint against.
Plain handlers registered with registry.register(name, fn) keep working unchanged. New context-aware handlers register with register_ctx and receive a TaskCtx that can persist per-stage state:
rust · worker
use chopflow_worker::TaskCtx;
use serde_json::{json, Value};
// async fn(TaskCtx, Value) -> Result<Value, String>async fningest(ctx: TaskCtx, payload: Value) -> Result<Value, String> {
// Skip a stage a previous attempt already finished.if ctx.stage_checkpoint("chunk").is_none() {
let chunks = chunk_document(&payload);
ctx.checkpoint("chunk", json!({ "chunks": chunks }))
.await?; // persisted (upsert) on the broker
}
// embed / index — checkpoint progress so a retry resumes mid-pipeline
}
registry.register_ctx("rag.ingest", ingest);
ctx.checkpoint(stage, payload) serializes the payload and upserts it keyed by (task_id, stage) — re-saving a stage overwrites it, so each stage has at most one checkpoint. ctx.stage_checkpoint(stage) and ctx.completed_stages() read what previous attempts recorded.
When a task fails mid-pipeline and retries, the worker re-fetches the task's checkpoints (a dedicated GetCheckpoints gRPC) before invoking the handler. The handler sees the stages that already completed and skips them, so only the unfinished tail runs again. Checkpoints are rows in the task store — with SQLite they survive broker restarts too.
Only the single-task view pays for the checkpoint read: GET /api/tasks/:id includes them, the list endpoint stays cheap. recorded_at is Unix epoch milliseconds.
The task drawer in the dashboard renders the declared stages as a timeline — completed stages with their checkpoint timestamp, the in-flight stage pulsing, pending stages muted. chopflow status get <id> prints stages, the idempotency key, and each checkpoint (stage (recorded_at): payload-preview).
Rate-Limit Resources
Worker resources are either static slots or replenishing token buckets. A replenishing resource models a provider quota — tokens return with time, not on release — so quota exhaustion becomes backpressure instead of a burst of failures.
A replenishing resource is a lazy token bucket — no background timers:
Allocate consumes. Claiming a task that requires the resource takes tokens from the bucket.
Release does not restore. When the task is acked, the tokens are not returned — a rate-limited call has been spent either way.
Refill is time-based and lazy. Tokens accrue at refill_amount / period_secs and are credited (capped at capacity) the next time the bucket is consulted.
Task-side requirements stay a plain map: a task requiring {"llm.rpm": 1} is only claimed once some worker's bucket holds at least one token, and otherwise simply waits in Queued.
A rate-limited task needs a declaring worker
A task requiring llm.rpm never dispatches unless a connected worker declares a replenishing llm.rpm resource — the same rule as any resource requirement. With no such worker (or an empty bucket), the task waits in the queue indefinitely; it does not fail.
Idempotent Submits
Give a submit an idempotency key and it becomes safe to retry: the broker returns the existing task instead of creating a duplicate — atomically, even under concurrent submits.
Pass idempotency_key in the POST /api/tasks body (or --idempotency-key on the CLI, or the field of the same name in the task JSON file — the flag wins):
The lookup-and-insert pair runs under a broker-wide submit lock, so two clients racing the same key resolve to a single created task — exactly one wins the 201, everyone else gets the existing task back. The key rides on the task (visible as idempotency_key in GET /api/tasks/:id, the CLI status output, and the dashboard's task drawer) and is unique across the store.
Core (Rust library)
The chopflow-core crate provides shared task models, state transitions, retry/schedule primitives, and the storage trait used by the broker, worker, and CLI.
The Task struct and TaskStatus enum, with builder methods (with_tag, with_eta, with_max_retries, with_resource) and state transitions (mark_running, mark_completed, mark_failed, mark_cancelled).
queue
The Queue abstraction over pending work.
dispatcher
The Dispatcher trait and an InMemoryDispatcher — worker registration, heartbeats, tag/resource matching.
resources
ResourceRequirements (task-side) and ResourceAvailability (worker-side).
retry
The RetryPolicy enum (NoRetry / Fixed / ExponentialBackoff) and ETA computation.
schedule
Schedule, ScheduleKind (Cron / OneShot), OverlapPolicy, and TaskTemplate.
storage
The Storage trait, TaskFilter, StatusCounts, and the InMemoryStorage / SqliteStorage implementations.
The crate root re-exports the primary types so downstream crates can write use chopflow_core::Task rather than reaching into modules:
core/src/lib.rs
pub use dispatcher::Dispatcher;
pub use queue::Queue;
pub use resources::ResourceRequirements;
pub use retry::RetryPolicy;
pub use schedule::{Schedule, ScheduleKind, OverlapPolicy, TaskTemplate};
pub use storage::{Storage, TaskFilter, StatusCounts, InMemoryStorage, SqliteStorage};
pub use task::Task;
The chopflow-broker crate is the server: it persists tasks, runs the schedule ticker, manages leases and retries, and serves both gRPC (for workers/CLI) and HTTP/JSON + the embedded dashboard (for browsers).
Both the gRPC service and the HTTP layer read and write a single BrokerState backed by one Storage implementation. The dashboard sees live updates from workers and vice versa — there is no second source of truth.
Open the dashboard in the default browser on start.
Worker
The chopflow-worker crate is a pull-based executor: it registers with the broker, polls for matching work, runs a handler, and acknowledges the result.
The worker repeatedly calls FetchTasks with its worker ID and a max_tasks bound. The broker matches queued, ETA-eligible tasks against the worker's tags and available resources, atomically transitions them to Running, and returns them. If no work matches, the worker sleeps for its poll interval and tries again.
After executing a handler, the worker calls AcknowledgeTask with success: true (and a JSON result) or success: false (and an error message). The broker records the outcome and either persists the result or applies the retry policy.
The demo worker (demos crate) is the reference implementation. It registers a handler map keyed by task name; the generic chopflow_worker binary uses the same loop. See Demos.
CLI
The chopflow command's enqueue / status / schedule subcommands are the operator's interface to a running broker. (The standalone chopflow_cli binary is equivalent — same flags, same crate — but the unified chopflow command is the recommended entry point.)
5-field cron expression (mutually exclusive with --eta).
--eta
—
RFC3339 one-shot time.
--payload
{}
Inline JSON payload.
--tags
—
Comma-separated tags.
--resources
—
Comma-separated name:amount.
--max-retries
3
Retries per materialized task.
--priority
0
Dispatch priority for materialized tasks (higher = first).
--overlap
skip
skip / coalesce / allow.
Embedded Dashboard
The broker embeds a live operations dashboard — no separate frontend deployment required. Serve it from the same binary with --open.
The dashboard is a React + Vite + Tailwind SPA in broker/ui/ (TypeScript, TanStack Query, framer-motion). The built bundle in broker/ui/dist/ is embedded into the broker binary via rust-embed, so a fresh clone runs the UI with no Node toolchain. The HTTP handler serves the SPA from any non-API path, falling back to index.html for client-side routing.
The SPA polls the HTTP API (see HTTP API Reference) every 2 seconds. There is no separate data plane — the dashboard is a first-class client of the same /api endpoints any external tooling would use, reading the same live BrokerState as the gRPC workers.
Native app
The same React frontend is wrapped as a native macOS app via Tauri 2 (app/src-tauri/). It points at http://127.0.0.1:8080 — nothing is forked or re-implemented.
The desktop app does not run the broker
The Tauri shell is a thin window around the React console — it is a client of the broker, not a host. It does not start the broker, a worker, or any ChopFlow server process. You must start chopflow_broker separately (gRPC :8000, HTTP :8080); the desktop app then connects to it just like a browser tab would.
$ pnpm --dir broker/ui install # first time only$ pnpm --dir broker/ui dev # http://localhost:5173, hot reload
The Vite dev server proxies /api to http://localhost:8080, so it runs against a live broker. After changes, rebuild the embedded bundle:
terminal
$ pnpm --dir broker/ui build$ touch broker/src/http.rs # force rust-embed to re-read ui/dist$ cargo build
Demos
The chopflow-demos crate ships a drop-in demo worker and a seeding tool so cargo run produces a live dashboard end-to-end.
The demo worker is structurally identical to worker/src/main.rs — the only difference is its handler registry. It registers four showcase handlers (plus echo and a default fallback):
Handler
What it does
resize_image
Generates a synthetic gradient PNG and resizes it (image-rs).
batch_compute
CPU-bound n × n f64 matrix multiply (nalgebra), reports timings.
simulate_pipeline
Multi-stage pipeline (download / process / upload) with staged sleeps.
flaky_handler
Fails ~30% of the time (seeded) to exercise the retry policy.
This builds the workspace, starts an in-memory broker with --open (dashboard at http://localhost:8080), starts a demo worker (--tags demo,ml --resources cpu:4), then seeds:
One task of each handler type — the flaky_handler one with max_retries: 5 so you can watch it retry.
A */2 * * * * cron schedule running batch_compute with overlap skip.
A one-shot simulate_pipeline schedule 5 minutes out with overlap allow.
Ctrl+C stops the broker and worker. You'll see four tasks flow through distinct lifecycles, the flaky handler visibly retrying, and two schedules ticking in the Schedules view.
The demo worker already registers resize_image and batch_compute, so once a broker and the demo worker are running you only need a producer to drive them. Because every client speaks the same gRPC contract, that producer can be Rust, Python, or Java — pick whichever client you ship in. The two demos below are enqueued from each current client.
Prerequisites
The fastest path is bash demos/run.sh — it starts an in-memory broker and the demo worker. Then run any producer below against the gRPC endpoint at localhost:8000. (Broker alone: chopflow broker start; demo worker alone: cargo run -p chopflow_demos --bin chopflow_demo_worker -- --tags demo --resources cpu:4.)
Generates a synthetic gradient PNG of width × height and resizes it to half dimensions (image-rs, nearest-neighbour). Returns input_dims, output_dims, and output_pixels. Payload defaults to 64×64 when omitted.
rust · producer
use chopflow::chop_flow_broker_client::ChopFlowBrokerClient;
use chopflow::EnqueueTaskRequest;
use tonic::Request;
use std::collections::HashMap;
let mut client = ChopFlowBrokerClient::connect("http://localhost:8000").await?;
let resp = client.enqueue_task(Request::new(EnqueueTaskRequest {
name: "resize_image".into(),
payload: r#"{"width":128,"height":128}"#.into(),
tags: vec!["image".into()],
eta: None,
max_retries: 3,
resources: HashMap::from[("cpu".into(), 2)],
priority: 0,
})).await?;
let task_id = resp.into_inner().task_id;
python · producer
from chopflow import ChopFlowClient
with ChopFlowClient.connect("localhost:8000") as client:
result = (
client.enqueue("resize_image")
.payload({"width": 128, "height": 128})
.tags("image")
.max_retries(3)
.enqueue()
)
task = result.get(timeout=60)
print(task.status_name, task.result)
# COMPLETED {"status":"ok","input_dims":[128,128],"output_dims":[64,64],"output_pixels":4096}
CPU-bound n × n f64 matrix multiply (nalgebra), with n clamped to 2–256. Returns matrix_size, elapsed_ms, and a checksum. Enqueue it at priority: 5 alongside lower-priority work to watch ordering and resource contention play out on the dashboard.
with ChopFlowClient.connect("localhost:8000") as client:
result = (
client.enqueue("batch_compute")
.payload({"n": 128})
.tags("demo")
.priority(5)
.max_retries(2)
.enqueue()
)
task = result.get(timeout=120)
print(task.status_name, task.result)
# COMPLETED {"status":"ok","matrix_size":128,"elapsed_ms":18,"checksum":...}
java · producer
try (ChopFlowClient client = ChopFlowClient.connect("localhost:8000")) {
var result = client.enqueue("batch_compute")
.payload(Map.of("n", 128))
.tags("demo")
.priority(5)
.maxRetries(2)
.enqueue();
var task = result.get(Duration.ofSeconds(120));
System.out.println(task.getResult()); // {"status":"ok","matrix_size":128,...}
}
Writing your own handler
These demos run on the Rust demo worker, but Python and Java workers can define equivalent handlers with the @task / @ChopTask decorators — see Python Client and Java Client. Any handler registered under the same task name on a connected worker will pick the task up.
RAG Demo
The rag.ingest demo puts every pipeline feature on one screen: a checkpointed chunk → embed → index flow, a replenishing llm.rpm token bucket gating dispatch, and an idempotency key deduplicating the submit.
$ bash demos/rag.sh ... cargo build output ...
ChopFlow gRPC on 127.0.0.1:8000
ChopFlow HTTP/ on 127.0.0.1:8080
... worker startup + the four standard demo-task submits ...
POST /tasks -> 201 Created
POST /tasks -> 200 OK
Idempotent submit: both rag.ingest submits returned the same task id 9f4a2c1e-... (deduplicated: true)
Seed complete. Open the dashboard at http://localhost:8080 — tasks flowing + 2 schedules ticking.
open http://localhost:8080 — watch the rag.ingest task's checkpoints grow chunk -> embed -> index
The script builds the workspace, starts an in-memory broker, starts the demo worker with --resources "cpu:4,llm.rpm:60@60/60" (a static CPU pool plus a replenishing 60-per-minute LLM quota), and seeds the demo tasks. Ctrl+C stops everything.
The task declares stages: ["chunk", "embed", "index"], requires {"llm.rpm": 1}, and carries the idempotency key rag-demo-doc-1 — submitted twice by the seed to prove the dedup. The handler is registered with register_ctx and checkpoints each stage:
Stage
What it does
Checkpoint payload
chunk
Splits the document into chunks of chunk_size words (default 64). Skipped entirely if a previous attempt already checkpointed it.
{"chunks":[...],"chunk_size":64}
embed
Produces a deterministic 16-dim pseudo-embedding per chunk (hash-based — no API calls). Checkpoints after every chunk, so a mid-flight retry resumes at chunk granularity.
{"next_index":i}
index
The notional vector-store write.
{"stored":n}
The finished task returns {"chunks":4,"dim":16,"indexed":true,"resumed_from":null} for the sample document (resumed_from names the stage a retry resumed from, null on a clean first run).
Pipeline stages timeline — open the rag.ingest task's drawer; as checkpoints land, chunk → embed → index fill in with timestamps (the embed stage even shows a 3/4-style progress note while it walks the chunk list).
Deduplication — exactly one rag.ingest task despite the double submit; the drawer also shows its idempotency key.
Rate-limit gating — the task sits in Queued until the worker's llm.rpm bucket has a token; it waits for quota rather than failing.
Try the resume
Kill the worker mid-embed (or force a failure) and let the retry pick the task up: the handler skips chunk, resumes embedding from the last next_index checkpoint, and the result's resumed_from names the stage it resumed from.
LLM Worker
The chopflow-llm-worker crate is a specialized worker that executes LLM inference tasks against an OpenAI-compatible endpoint — turning ChopFlow into an LLM-job orchestrator an agent can drive through the MCP.
It uses the same pull-based protocol as the generic worker (register → heartbeat → FetchTasks → AcknowledgeTask), but with two differences: handlers are async (each inference call is network I/O), and it runs a bounded concurrency pool so a single worker pipelines multiple in-flight requests rather than blocking on one at a time.
With a worker running, an agent (or the CLI) can enqueue an llm.complete task tagged llm; the worker picks it up, calls the model, and acks the result. The MCP run_llm_task tool does this end-to-end and returns the model's text — see run_llm_task.
API key
Never commit an API key. Pass it via the environment or a gitignored .env; the worker reads it from OPENAI_API_KEY if the --api-key flag is omitted.
Rust Client
Rust is the first-class client language. Speak gRPC to the broker with the tonic client generated from proto/proto/chopflow.proto, or embed chopflow-core in-process for tests.
For tests or single-process embedding, use chopflow-core directly against an InMemoryStorage — no broker process needed:
rust
use chopflow_core::storage::InMemoryStorage;
use chopflow_core::task::{Task, TaskStatus};
let storage = InMemoryStorage::new();
let mut task = Task::new("echo", serde_json::json!({"msg":"hi"}))
.with_tags(vec!["demo".into()]);
task.status = TaskStatus::Queued;
storage.insert(task.clone()).await?;
Where the client lives
There is no separate chopflow-client crate today. The gRPC client is generated from the proto by whichever crate needs it (worker, cli, demos). Reuse proto/proto/chopflow.proto to generate it in your own crate.
Python Client
The Python client brings Celery-like ergonomics — a @task decorator, .delay(), and an AsyncResult handle — speaking gRPC to the broker.
Query params: status (one of created|queued|running|completed|failed|dead-lettered|cancelled), limit, offset. Returns a page plus the unfiltered total.
Cancels a non-terminal task. Returns {"success": false} if the task is already terminal (Completed, Failed, DeadLettered, Cancelled) or not found. If the task was Running, the broker also releases it from the assigned worker.
response · 200
{ "success": true }
API — Schedules
Recurring and one-shot schedule endpoints: list, create, update, and delete. See Scheduling & Recurrence for semantics.
Returns every schedule (enabled and disabled). Each carries a kind discriminated by type: {"type":"cron","cron":"0 9 * * *"} or {"type":"oneshot","eta":"2026-09-04T14:30:00Z"}.
Partial update. All fields optional: enabled (pause/resume), overlap_policy, cron (replaces the kind and recomputes next_fire). Returns the updated schedule.
alive is derived from last_heartbeat against the broker's heartbeat timeout. A dead worker's assigned tasks are eligible for reclamation on the next reconcile.
Liveness/readiness probe. Returns 200 OK with body ok. Use it for load-balancer health checks and container orchestrators — it does not report broker internals.
API — Environments
Read-only fleet catalog and the broker's own identity. The dashboard polls this to render its environment switcher. See Environments for the concepts and configuration.
Returns the broker's own identity (current) and the full fleet catalog (environments) loaded from environments.yml. The current entry always carries an empty http_url: the serving broker is same-origin to its own dashboard, so selecting current must not retarget the API. No query parameters.
The broker's own identity, derived from --env/--region. http_url is always empty (same-origin signal to the dashboard).
environments
The full catalog from environments.yml. Empty when no catalog is configured (standalone broker).
Read-only
This endpoint serves the catalog as-is; it does not mutate fleet configuration. Environments are declared in config/environments.yml and reloaded on broker start.
gRPC Reference
The broker serves a gRPC API on the configured --port (default 8000) via tonic. This is the wire protocol workers and clients speak; the HTTP API is a thin facade over the same BrokerState.
The service is defined in proto/proto/chopflow.proto (package chopflow). Generate a client in any language with gRPC tooling.
Task carries id, name, JSON payload, tags, enqueue_time/eta, retry_count/max_retries, status, a resources map, result (empty until terminal), schedule_id (set when materialized by a schedule), and priority (default 0; higher is claimed first — see Priority & Ordering). TaskStatus is the enum: CREATED=0, QUEUED=1, RUNNING=2, COMPLETED=3, FAILED=4, DEADLETTERED=5, CANCELLED=6.
Pull, don't push
The broker never pushes tasks to workers. A worker calls FetchTasks on its own poll loop, so a slow worker simply stops pulling — backpressure is implicit, no flow-control signaling needed.
MCP Server
A Model Context Protocol server that exposes the ChopFlow broker through all three MCP primitives — tools, resources, and prompts — so an assistant like Claude Desktop, Cursor, or Cherry can enqueue tasks, run LLM jobs, inspect their lifecycle, and manage schedules with no glue code.
The chopflow-mcp crate is a thin wrapper over the broker's HTTP/JSON API. It adds no new broker surface: every tool is a 1:1 wrapper over an HTTP route. The server speaks JSON-RPC over stdio using the official Rust SDK (rmcp); tool input schemas are derived from typed param structs (schemars::JsonSchema), so clients see rich, typed arguments with no manual schema work.
Phase 2 shipped — agent-native
Beyond the Phase 1 control tools, the server now exposes resources (live cluster state an agent can read), prompts (ready-made agent workflows), and two higher-level tools — run_llm_task and wait_for_task. Paired with the chopflow-llm-worker crate, an agent can enqueue an LLM completion and get the answer back synchronously: ChopFlow drives the model. Phase 3 (live notifications) is on the roadmap.
Thirteen tools. Eleven wrap a broker HTTP route 1:1; two are higher-level helpers for LLM workflows. All tool results are the broker's JSON response body returned as text.
Tool
Broker route
What it does
get_stats
GET /api/stats
Aggregate queue / worker / schedule counters.
list_tasks
GET /api/tasks
List tasks, optional status filter + pagination.
get_task
GET /api/tasks/:id
Fetch one task by UUID (status, retries, result, lineage).
enqueue_task
POST /api/tasks
Enqueue a task for async execution; returns the task UUID.
cancel_task
POST /api/tasks/:id/cancel
Cancel a non-terminal task.
list_workers
GET /api/workers
List registered workers + resource availability.
list_schedules
GET /api/schedules
List all schedules (cron + oneshot).
get_schedule
GET /api/schedules/:id
Fetch one schedule by UUID.
create_schedule
POST /api/schedules
Create a cron or one-shot schedule.
update_schedule
PATCH /api/schedules/:id
Patch a schedule (enable/disable, overlap, cron).
delete_schedule
DELETE /api/schedules/:id
Delete a schedule.
wait_for_task
GET /api/tasks/:id (poll)
Block (polling the broker) until a task reaches a terminal status, then return its full JSON. Gives a synchronous-style answer from an async task.
run_llm_task
POST /api/tasks + poll
Phase 2. Enqueue an llm.complete task (routed to an LLM worker via the llm tag), wait for completion, return the task JSON with the model's text in result.text. Requires chopflow-llm-worker to be running.
get_stats · list_workers · list_schedules
Take no parameters. Call them with an empty arguments object.
New cron expression; switches the schedule to Cron and recomputes next_fire.
Unspecified fields are left as-is. This is a partial update (PATCH semantics).
wait_for_task
Polls get_task roughly twice per second until the task reaches a terminal status (completed, failed, dead-lettered, or cancelled), then returns the full task JSON. Useful for getting a synchronous-style answer from an async task.
Field
Type
Required
Description
id
string
yes
The task UUID to wait on.
timeout_seconds
u64
no
Max seconds to wait before giving up (default 120).
run_llm_task
Phase 2. Enqueues an llm.complete task (routed to an LLM worker via the llm tag), waits for it to finish, and returns the final task JSON — with the model's text in result.text. This is the agent-friendly entry point to ChopFlow's LLM orchestration: an agent gets a synchronous answer back from an async LLM job.
Requires an LLM worker
run_llm_task enqueues a task tagged llm. A chopflow-llm-worker process must be running and subscribed to the llm tag, or the task will sit queued until the timeout.
Field
Type
Required
Description
prompt
string
yes
The prompt to send to the LLM.
model
string
no
Override the LLM worker's default model for this call.
temperature
f64
no
Sampling temperature (0.0–2.0).
max_tokens
u32
no
Max tokens to generate.
tags
string[]
no
Routing tags. Defaults to ["llm"] so an LLM worker picks it up.
Live, readable cluster state. An agent reads these to understand the system without firing tools blindly — resources/read fetches fresh data from the broker at read time. Returned as JSON text content.
URI
What it returns
chopflow://stats
Live aggregate counters (queue depth, processing, completed/failed, workers, schedules).
chopflow://workers
Live list of registered workers, their tags, and resource availability.
chopflow://tasks/recent
The 20 most recent tasks across all statuses.
chopflow://guide
A static text guide: task names, payload shapes, tag routing, and the LLM worker.
Ready-made agent workflows. prompts/get returns a seeded user message describing a multi-step task, parameterized by prompt arguments. The agent then carries it out using the tools above.
Prompt
Arguments
What it sets up
run-llm-completion
prompt (required)
Run a single LLM completion via run_llm_task and return the answer.
process-image-batch
count (optional, default 5)
Enqueue N resize_image tasks, wait, summarize results.
debug-stuck-tasks
—
List failed/dead-lettered tasks, inspect, propose fixes.
A non-2xx broker response surfaces as a tool error (isError: true) carrying the broker's {"error": "..."} body. Transport failures (broker unreachable, response read failure) return a descriptive error string instead. There is no retry inside the server — the caller decides what to do.
The server is launched as a child process by the MCP client. For Claude Desktop / Cursor, add it to claude_desktop_config.json — the unified chopflow command takes mcp as its first arg:
The server speaks plain JSON-RPC over stdio, so you can drive it without an MCP client. Start the broker, then pipe an initialize + notifications/initialized + a tools/call into it:
Use "method":"tools/list" to enumerate all 13 tools and their input schemas, "resources/list" for the four resources, and "prompts/list" for the four prompts.
Source
The server is a single file: mcp/src/main.rs. Tool parameter structs live alongside their #[tool]-annotated methods; resources and prompts are manual ServerHandler trait overrides. The typed fields documented above are the source of truth.
Storage — SQLite
Default storage backend. SQLite in WAL mode for durable persistence with minimal operational overhead.
SqliteStorage holds a single rusqlite connection behind a std::sync::Mutex, with the SQLite library bundled in (no system dependency). All methods are async; synchronous database work is offloaded to tokio::task::spawn_blocking so the runtime is never blocked.
On open, SqliteStorage::reconcile walks every Running task and resets it to Queued. After a broker restart the workers that claimed those tasks are gone, so leaving them Running would orphan them. Re-queuing makes them eligible for the next live worker to claim.
At-least-once
Reconciliation means a task that was mid-flight may execute again after a restart. Design handlers to be idempotent where the side effect matters.
Storage — In-Memory
In-memory storage backend for testing and demos. Not durable — all state is lost on restart.
InMemoryStorage is a HashMap behind a tokio::Mutex. It implements the same Storage trait as SqliteStorage, so it is a drop-in for ephemeral runs. Select it with --storage memory.
Nothing persists. Stop the broker and every task, worker, and schedule is gone. This is the backend demos/run.sh uses so demo runs start clean.
Because both backends implement the same trait, you can also construct an InMemoryStorage directly in a Rust test to exercise dispatch, retry, and scheduling logic with no I/O — see Rust Client → In-process.
Environments
An environment is a named, region-tagged broker deployment. Declare a small fleet catalog and the dashboard can hop between brokers at runtime; the CLI resolves --env <name> to the right gRPC address. Workers are unchanged — they stay single-homed to one broker.
ChopFlow is a single-binary system: one broker process owns its storage and its workers. The environments feature does not federate brokers or share state between them. It is a lightweight operations layer — an identity tag per broker plus a read-only catalog — so a human operating several brokers (local, staging, prod) can point the same dashboard and CLI at each one without editing URLs by hand.
Every broker advertises an env and region, set with flags on broker start. These appear on /api/stats and as the current entry of /api/environments, so the dashboard always shows which broker it is talking to.
Flag
Default
Purpose
--env
local
Environment name this broker identifies as. Matches a catalog entry's name for the "current" row.
--region
default
Free-form region tag (e.g. us-east-1, eu-west-1).
--environments
config/environments.yml
Path to the fleet catalog. Optional — a missing file is not an error.
The catalog is a single YAML file listing every known environment. It is optional: a broker with no catalog still runs and advertises itself — its --env/--region become the lone "current" entry. Add entries only when you operate more than one broker and want the dashboard to switch between them.
The TopBar cluster selector lists every catalog entry. Picking one retargets the dashboard's reactive API base to that broker's http_url and invalidates all queries, so the whole UI re-fetches from the newly selected broker. The current entry always has an empty http_url — the serving broker is same-origin to its own dashboard, so selecting "current" never retargets to a possibly-wrong host. A broker whose --env is not in any catalog still synthesizes a current entry so the selector has something to display.
Cross-origin
Switching to a remote broker is a cross-origin request. The broker answers with permissive CORS headers (it mirrors the request Origin), so a dashboard served by broker A can fetch broker B's API without extra proxying.
The CLI verbs (enqueue, status, schedule) accept --env <name>, which resolves the broker gRPC URL from the catalog and overrides --broker. Pair it with --env-config to point at a non-default catalog. Unknown names and entries missing grpc_url error out with a helpful message listing the known environments.
Environments add no worker-side concept. A worker still connects to exactly one broker gRPC endpoint and is unaware of the catalog. To move a worker between environments, point it at a different --broker address — there is no federation, no cross-broker task migration, and no shared storage. This keeps the dispatch path exactly as described in Architecture.
Architecture
ChopFlow is a single-binary system with a pull-based dispatch model, durable persistence, and an embedded dashboard. Seven workspace crates, one shared broker state.
The broker holds a single BrokerState — a shared handle to the Storage and the Dispatcher. Both the gRPC service (:8000, for workers and clients) and the HTTP API + dashboard (:8080) read and write the same live state. A task enqueued from the web UI is immediately visible to a gRPC worker's next FetchTasks, and vice versa — no sync step, no separate data plane.
The broker never pushes tasks. A worker calls FetchTasks on its own poll loop; the broker atomically claims matching ready tasks (Queued → Running). A slow or saturated worker simply stops pulling, so backpressure is implicit — there is no flow-control signaling to implement or misconfigure.
The broker, the dashboard, and the HTTP API ship in a single binary so an operator deploys one process and gets a live, observable cluster. The dashboard is embedded via rust-embed (no separate frontend host); storage defaults to a local SQLite file (no external database to provision). Workers and the CLI are separate binaries that talk to the broker over gRPC.
Category
ChopFlow is a task queue (Celery / Ray / Dask lineage), not a workflow engine. It moves independent units of work through an observable lifecycle across a worker fleet — it does not orchestrate multi-step sagas or long-running workflows the way Temporal does.
macOS App
ChopFlow ships a small macOS desktop app that supervises your local stack: it starts and watches the broker, optionally runs the persistent MCP gateway, and gives you tray shortcuts and a Settings screen. The dashboard itself is the same embedded UI the broker serves.
Download & install
Grab the latest .dmg from the GitHub Releases page (the landing page's Download the macOS App — Free! button points there). The app bundles its own chopflow CLI sidecar, so no extra install is required. Since it is not notarized yet, first launch uses right-click → Open on macOS.
First launch
On a fresh install the Welcome wizard walks through four steps before the dashboard loads:
Environment — start the local broker or connect to a remote one you already run.
MCP — optionally enable the persistent MCP gateway and decide whether it requires its own access token.
Security — optionally protect the broker: turn the switch on and create the first labelled token (shown once).
Learn — links to the Rust / Python / Java worker guides.
“Finish setup” shows the loading rail, then the dashboard.
Menu bar (tray)
The app lives in the macOS menu bar: Open · Local broker (Start/Stop) · MCP (Start/Stop + copy endpoint) · Remote brokers (live status, click to switch) · Quit. With the window focused, the native ChopFlow menu offers Settings… (⌘,).
Settings
Tab
What it does
Storage
Where data lives (data folder + chopflow.db), change the data folder with the native picker, local ports, binary + version.
Remote servers
Add/remove brokers this app connects to (name, URL, optional Bearer token) — the browser dashboard's equivalent is an environment from environments.yml.
MCP
Toggle the HTTP gateway, set/generate/regenerate/remove its access token, and decide whether it's required.
Security
Set the broker's API tokens (labelled, one per client), plus the on/off switch that requires them.
Logs
Live ring buffer with Copy and Download .log.
Danger zone
Delete all local information (wipes connections + database and returns to first run) and a non-destructive Preview first-run welcome.
Data privacy Everything is local: connections and tokens live in the app-data folder (~/Library/Application Support/io.chopflow.ops), the broker database is a local SQLite file, and nothing phones home.
Broker API Tokens
Start the broker with --api-token to require a Bearer token on every /api/* request. /healthz stays open so adopt-probes and load balancers keep working.
CLI
The flag is repeatable: each occurrence adds an equally valid credential. A bare --api-token auto-generates one (printed at startup); CHOPFLOW_API_TOKEN is honored when no flag is given; no flag at all means no auth.
# one explicit token
chopflow broker start --api-token my-secret
# several tokens (one per client) + auto-generated
chopflow broker start --api-token alpha --api-token beta
# token from the environment
CHOPFLOW_API_TOKEN=sekret chopflow broker start
Requests without a matching Authorization: Bearer <token> get 401; /api/stats and /api/environments report auth_required: true so clients know.
From the desktop app
Wizard (first run): the Security step asks whether to protect the broker; turning it on creates the first labelled token (identifier + value, shown once).
Settings → Security: a switch turns authorization on/off (non-destructive — it never creates or deletes tokens by itself), and below it you add tokens (label + value, with Generate) and revoke them individually. Protection is enforced the moment a token exists and the switch is on.
From config / files
Vending a fleet catalog is done via config/environments.yml; tokens are not stored there. Paste a generated token into the Token (optional) field when adding a remote in the app, and remote gates are per-broker.
MCP Access Tokens
The chopflow mcp --http gateway is an AI-facing endpoint. By default anyone who can reach it can use it. Give it its own access token with --access-token — independent from the broker's API tokens.
CLI
# gateway serving the broker, requiring a token to reach /mcp
chopflow mcp --http 127.0.0.1:8810 --broker http://127.0.0.1:8080 \
--access-token mcp-secret \
--api-token <broker-token-if-protected>
Clients present the token via Authorization: Bearer <token> or ?access_token=/?code= URL params (both are in the MCP streamable-HTTP spec). Missing/invalid values return 401.
From the desktop app
Wizard (first run): the MCP step can enable the gateway and toggle Requires token to connect — turning it on generates the gateway token (shown once).
Settings → MCP: toggle the gateway, then use the Access token section: the on/off switch Requires token to connect (non-destructive, keeps the stored value), plus Set / Generate / Regenerate / Remove and Show/Hide.
Note--api-token on the MCP command authenticates the gateway to the broker; --access-token gates who can reach the gateway itself. They are orthogonal.
FAQ
Frequently asked questions about ChopFlow — the system, the model, and where it fits.
What language is ChopFlow written in, and what can talk to it?#
The core broker and workers are written in Rust (2021 edition). Rust is the first-class client language; Python and Java clients are also available. Workers and the CLI speak gRPC to the broker; browsers and external tooling speak HTTP/JSON against the same live BrokerState.
Tasks persist in SQLite (or the in-memory backend for tests). On boot the broker reconciles in-flight work: any task left Running from a previous run is reset to Queued, since its worker is gone. No silently dropped work — see Storage — SQLite → Restart reconciliation.
No. The dashboard is embedded in the broker binary via rust-embed and served on the HTTP port — one process, no Node build required to run, no separate deploy. The same UI also ships as a native macOS app through Tauri.
Workers acknowledge each task with a success or structured failure. Failed tasks follow a configurable RetryPolicy (fixed or exponential backoff). Tasks that exhaust their retries are DeadLettered so they stay visible instead of vanishing — see Retry & Dead-Letter.
Yes. Tasks carry tags and resource requirements; workers register with tags (e.g. gpu,ml) and capacity (CPU, GPU, memory). The dispatcher only hands a task to a worker whose tags match and whose resources are available — see Resources & Tags.
Different category. Temporal is a durable workflow engine — it orchestrates long-running, multi-step processes via event-sourced replay. ChopFlow is a task queue in the Celery / Ray / Dask lineage: discrete units of work, a pull-based dispatch model, leases, retries, and dead-lettering. If you're scheduling background jobs across a worker fleet and observing them live, that's ChopFlow's shape; if you need saga-style workflows with checkpointed replay, Temporal is the right tool.
Both. It's built for production applications and for distributed-systems research. The experiment directory (reproducible configs and analysis scripts) is planned but not yet landed; the demos crate is the current way to exercise the system end-to-end.
No. On startup, enabled cron schedules have next_fire recomputed from now, so the ticker only fires future matches. A disabled one-shot with a past ETA stays disabled until re-enabled. See Scheduling & Recurrence.
The broker never pushes tasks. A worker calls FetchTasks on its own poll loop, so a slow or saturated worker simply stops pulling — backpressure is implicit, with no flow-control signaling to implement or misconfigure.