Workflows
A finite job with a stable runId, status, payload, result, and event log. Stage inputs, drive an agent with run_agent, capture artifacts — run it as a Task or an Action.
An agent accumulates context — it keeps a conversation going. A workflow fires and finishes: a finite job with a stable runId, a status, a payload, a result, and an ordered, inspectable event log. Subclass Nexo::Workflow, implement #call(payload), and run it.
Lifecycle
require "nexo"
class SummarizeDocument < Nexo::Workflow
def call(payload)
emit(:started, doc_id: payload[:doc_id])
summary = payload[:text].to_s.slice(0, 280) # pure Ruby — no Agent needed
emit(:summarized, length: summary.length)
{ summary: summary }
end
end
run = SummarizeDocument.run(doc_id: 123, text: "Long text…")
run.id # => "0191d6b2-…" (UUID v7 string, time-ordered)
run.status # => "done"
run.result # => { "summary" => "Long text…" }
#call receives a symbol-keyed payload; the stored payload and result read back string-keyed (they survive a JSON round-trip identically whether the run lives in memory or in the database).
Failure model — workflows re-raise
A workflow that raises is recorded as failed with the error message and the exception still propagates to your caller:
run = BoomWorkflow.run # raises — but the run is persisted as failed first
# => RuntimeError: kaboom
This is deliberately the opposite of a Nexo tool failure, which returns { error: … } and never raises into the agent loop. A tool error is recoverable context for the model; a workflow failure is a job that did not complete. By default a failed run is not retried — the exception is yours to handle. Runs orphaned in "running" by a crashed worker are swept to "interrupted" by reconcile_interrupted!.
The event log — emit and nexo logs
emit(:type, data) appends an ordered event (type, data, at) and persists it incrementally. Inspect a run’s log in plain Ruby:
Nexo::Workflow.logs(run.id) { |ev| puts "#{ev["at"]} #{ev["type"]}" }
or, in a Rails app, from the terminal:
$ bundle exec rake "nexo:logs[0191d6b2-7c4a-7e1f-9a3b-2f5c8d1e6b00]"
[2026-06-29T14:02:01Z] started {"doc_id"=>123}
[2026-06-29T14:02:01Z] summarized {"length"=>280}
With or without Rails
With no Rails loaded, runs record to an in-memory store — workflows run, emit, and Nexo::Workflow.logs works, all offline with no database. In a Rails app, install the migration and runs persist to a nexo_workflow_runs table:
rails g nexo:workflows
rails db:migrate
The same Workflow code drives either backend; Nexo::RunStore.default selects ActiveRecord when it is available and the in-memory store otherwise. The schema uses portable json columns (SQLite and PostgreSQL alike) and a UUID string primary key.
Input staging and artifacts
A run owns a sandbox — declared with the sandbox class macro (default :virtual; :local for the host filesystem rooted at the cwd macro, default Dir.pwd). It is resolved lazily: a data-only workflow that never touches files builds nothing. A Workflow accepts the same sandbox forms as an Agent — they share one resolver (Nexo::Sandboxes.resolve), so the two can’t drift, including a hardened container:
class BuildInContainer < Nexo::Workflow
sandbox :docker, image: "node:22-slim" # or :apple, or { type: :docker, ... }
def call(_payload) = { ok: true }
end
stage(files) writes provided inputs into that sandbox before your #call work begins. It takes either a { "path" => "content" } hash or an array of { path:, content: } hashes, emits a :staged event with the count, and returns the count staged.
artifact(name, content:) records a named deliverable on the run — a digest, a report, an improved file, a generated script. The body is written to the sandbox at /artifacts/<name> (so later steps can read it) and recorded on the run. run.artifacts reads it back as an ordered array of string-keyed hashes ({"name" =>, "content" =>, "at" =>}):
class BuildDigest < Nexo::Workflow
def call(payload)
stage(payload[:files]) # baseline + extras into the sandbox
artifact("digest.md", content: summarize(sandbox.read("/workspace/baseline.md")))
{ ok: true }
end
end
run = BuildDigest.run(files: [{ path: "baseline.md", content: "…" }])
run.artifacts.first["name"] # => "digest.md"
run.artifacts.first["content"] # => "…the digest body…"
You can also render an artifact from a template you control with from: — no templating engine, just stdlib ERB:
# from: is a real disk file when it exists, else a staged sandbox path.
artifact("digest.md", from: "app/templates/digest.md.erb",
locals: { title: "Weekly", baseline: sandbox.read("/workspace/baseline.md") })
Templates are code, not data.
ERBexecutes arbitrary Ruby. A template passed toartifact(from:)must be a trusted, developer-authored file — never model output or user-uploaded content. Rendering a model-generated or uploaded template is remote code execution. If a body is untrusted, pass it ascontent:(inert data), not as afrom:template.
The artifacts column ships with fresh installs. Apps installed before this release add it with:
rails g nexo:artifacts
rails db:migrate
Live example
The full offline artifact-from-template flow is runnable in the repo:
ruby -Ilib examples/artifact_from_template.rb
Tasks & Actions — drive an agent
A workflow can declare and drive an agent so the two primitives Nexo owns — a Workflow (the run lifecycle) and an Agent (the skilled, sandbox-backed model loop) — compose into one recipe: stage inputs → run the agent → capture artifacts. The agent class macro names the Agent subclass this workflow drives; run_agent(prompt, max_turns: 25) runs it bound to the run’s own sandbox, forwards every tool call/result and the final response into the run log as agent_* events, and closes the agent afterward.
class ReviewBaseline < Nexo::Workflow
agent CodeReviewer # the Agent subclass this workflow drives
def call(payload)
stage(payload[:files]) # inputs into the run's sandbox
resp = run_agent("Review the staged baseline and report OK or the issues.")
artifact("review.md", content: resp.content) # capture the agent's output
{ content: resp.content }
end
end
A driven run reads as one coherent story — Nexo::Workflow.logs(run.id) (and nexo:logs) interleaves the workflow’s own events with the agent’s:
[…] staged {"count"=>1}
[…] agent_tool_call {"name"=>"read_file", "args"=>{"path"=>"/workspace/baseline.md"}}
[…] agent_tool_result {"ok"=>true, "content"=>"…"}
[…] agent_done {"content"=>"REVIEW OK"}
The same workflow runs two ways with no code difference — Nexo stays schedulable, never a scheduler.
As a scheduled Task — invoke it from a background job (the scheduling itself lives in the host):
class ReviewBaselineJob < ApplicationJob
def perform(files:)
ReviewBaseline.run(files: files) # same run entry point
end
end
# scheduled elsewhere in the host — Nexo does not schedule:
ReviewBaselineJob.perform_later(files: nightly_baseline)
As an interactive Action — invoke the same run from a controller after staging the uploaded files:
class ReviewsController < ApplicationController
def create
files = params[:files].map { |f| { path: f.original_filename, content: f.read } }
run = ReviewBaseline.run(files: files) # identical call — no code difference
redirect_to review_path(run.id)
end
end
Shared-sandbox precedence. Under
run_agentthe agent uses the workflow’s sandbox; the agent’s ownsandboxclass macro is ignored (it only applies when the agent runs standalone). The agent keeps its ownpermissions,skills,mcp, andmcp_allow: the workflow provides the where (sandbox), the agent owns the what (permissions) and the how (skills/instructions). Driving an agent never widens its authority — its safe default (:read_only) is untouched.
Live example
A live example wraps an MCP-backed agent in a workflow and captures the digest as an artifact:
Reconciling interrupted runs
A crashed worker leaves runs stuck in "running". Nexo::Workflow.reconcile_interrupted! is a one-shot boot/deploy sweep that rewrites only "running" → "interrupted" (never touching "done" or "failed") and returns the count. It is never auto-invoked — call it from a boot hook or the shipped rake task:
bundle exec rake nexo:reconcile
This is not a liveness check. It cannot tell a genuinely-running run in another process from an orphaned one — so run it once at boot, before any worker starts new runs, not while workers are live.
Where to next
- Durable workflows —
checkpoint/suspend!/resumefor long-running and human-in-the-loop jobs. - Rails —
run_later, live progress broadcasting, and run-query helpers for a host UI.