Skip to main content

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. ERB executes arbitrary Ruby. A template passed to artifact(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 as content: (inert data), not as a from: 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

View examples/artifact_from_template.rb on GitHub →


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_agent the agent uses the workflow’s sandbox; the agent’s own sandbox class macro is ignored (it only applies when the agent runs standalone). The agent keeps its own permissions, skills, mcp, and mcp_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:

View examples/inbox_digest_task.rb on GitHub →


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 workflowscheckpoint / suspend! / resume for long-running and human-in-the-loop jobs.
  • Railsrun_later, live progress broadcasting, and run-query helpers for a host UI.

Next steps