
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

```ruby
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:

```ruby
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:

```ruby
Nexo::Workflow.logs(run.id) { |ev| puts "#{ev["at"]} #{ev["type"]}" }
```

or, in a Rails app, from the terminal:

```sh
$ 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:

```sh
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:

```ruby
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" =>}`):

```ruby
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`:

```ruby
# 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:

```sh
rails g nexo:artifacts
rails db:migrate
```

### Live example

The full offline artifact-from-template flow is runnable in the repo:

```sh
ruby -Ilib examples/artifact_from_template.rb
```

> [View `examples/artifact_from_template.rb` on GitHub →](https://github.com/maquina-app/nexo/blob/main/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.

```ruby
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):

```ruby
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:

```ruby
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 →](https://github.com/maquina-app/nexo/blob/main/examples/inbox_digest_task.rb)

---

## 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:

```sh
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](../durable-workflows/) — `checkpoint` / `suspend!` / `resume` for long-running and human-in-the-loop jobs.
- [Rails](../rails/) — `run_later`, live progress broadcasting, and run-query helpers for a host UI.


---

## Next steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="../durable-workflows/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Durable workflows
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Pause a run durably and resume it later without redoing work.
    </p>
  </a>

  <a href="../rails/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Rails
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Run the same workflow in the background with run_later.
    </p>
  </a>
</div>
