
Rails wiring: run a `Workflow` asynchronously on your **existing** ActiveJob adapter, broadcast its events live, and query runs and artifacts from your own controllers. Nexo ships **no queue, no scheduler, no cable backend, and no UI** — only the primitives plus one overridable partial.

The install generator (`rails g nexo:install`) is covered in [Getting started](../getting-started/); the per-feature generators (`rails g nexo:workflows`, `nexo:artifacts`, `nexo:state`, `nexo:skill`) live with their topics in [Workflows](../workflows/), [Durable workflows](../durable-workflows/), and [Skills](../skills/).

---

## Install the store (needed for cross-process `run_later`)

`run_later` enqueues a job that carries only the run id; the worker looks the run up in the store. For a worker in **another process** to find it, use the ActiveRecord store:

```sh
rails g nexo:install     # config/initializers/nexo.rb
rails g nexo:workflows   # the nexo_workflow_runs migration
rails db:migrate
```

In `config/initializers/nexo.rb`, opt into the pieces you want:

```ruby
Nexo.configure do |config|
  config.default_model = ENV["NEXO_MODEL"]
  config.job_queue = :nexo         # route workflow jobs to a dedicated queue (optional)
  config.broadcast_events = true   # opt-in Turbo mirror (requires turbo-rails)
end
```

---

## Background execution — `run_later`

`MyWorkflow.run_later(payload)` enqueues the run on your **existing ActiveJob adapter** and hands back the run **immediately** (status `"queued"`), so a controller can return while the work happens in the background. The job carries **only the run id** — the payload lives on the run record, so no arguments (and no secrets) travel through the queue. When the worker picks it up, it reconstitutes the workflow and calls the same `execute` the sync path uses, so an async run reaches the identical `done`/`failed` lifecycle, event log, and status notifications:

```ruby
class GenerateReport < Nexo::Workflow
  def call(payload) = { url: build_report(payload[:account_id]) }
end

run = GenerateReport.run_later(account_id: 42)   # returns at once
run.status                                        # => "queued"
# ...the worker runs it in the background; later:
Nexo::RunStore.default.find(run.id).status        # => "done"
```

Route jobs to a dedicated queue per call or globally:

```ruby
GenerateReport.run_later(account_id: 42, queue: :nexo)  # per-call
Nexo.configure { |c| c.job_queue = :nexo }              # or a global default
```

### Scheduling a future run or resume

`run_later` and `resume_later` accept `wait:` (a duration) or `wait_until:` (an absolute time), forwarded straight to **the installed ActiveJob's own** `.set(...)` scheduler — Nexo adds no scheduler of its own. Use them to defer an initial enqueue ("send this digest at 9am") or to let a suspended run wake itself on a timer, symmetrically:

```ruby
# Defer the initial enqueue until tomorrow morning.
DailyDigest.run_later({account_id: 42}, wait_until: Date.tomorrow.noon)

# Let a suspended run wake itself up in an hour (no separate scheduled job).
MyWorkflow.resume_later(run.id, {reminder: true}, wait: 1.hour)
```

The run's status is unchanged — a scheduled `run_later` is still `"queued"` (no `"scheduled"` status is invented), and a scheduled `resume_later` leaves the run `"suspended"` until the job fires. Passing **both** `wait:` and `wait_until:` in one call raises `ArgumentError` (checked before any run is created or job enqueued). With neither given, the enqueue is byte-for-byte the immediate one above.

> **`wait:`/`wait_until:`/`queue:` are scheduling options, not payload.** A bare-keyword call consumes them as options: `run_later(wait: 60)` schedules the job **60 seconds out** and leaves the payload `{}` — it does **not** store `"wait" => 60` as data. A payload that legitimately needs a key named `"wait"` must be passed as an explicit positional Hash: `run_later({wait: "value"})`.

This is still "no scheduler, no cron" — `wait:`/`wait_until:` schedule a **single** future run/resume via ActiveJob; recurring schedules stay the host's.

### No queue, no scheduler — and the honest caveats

Nexo ships **no queue and no scheduler** — ActiveJob uses whatever adapter your app configured (Sidekiq, GoodJob, Solid Queue, …), and scheduling (cron / GoodJob / `whenever`) stays the host's. Without ActiveJob, `run_later` raises `Nexo::MissingDependencyError` — use `run` for synchronous execution.

> **Needs a shared store.** For a worker in **another process** to find the run, use the ActiveRecord store with a real adapter — the run must live in the database, not in a per-process memory store. The in-memory store only works under the `:inline`/`:test` adapters, where the job runs in-process on enqueue.
>
> **No automatic crash recovery / no automatic retries.** A crashed or retried job re-runs `#call` **from scratch** — Nexo adds no `retry_on` (configure retries in your host job if you want them). Pair with `reconcile_interrupted!` ([Workflows](../workflows/)) to sweep runs orphaned in `"running"`. For an *intentional* pause-and-continue, see **[Durable workflows](../durable-workflows/)** — `checkpoint` skips already-paid-for work when a run resumes.

---

## Live progress — notifications and opt-in Turbo

Every run **broadcasts as it happens** over `ActiveSupport::Notifications`, decoupled from persistence (events still buffer/persist separately). Two notifications fire (a no-op with no ActiveSupport, so the plain-Ruby core stays Rails-free):

- `nexo.workflow.event` — one per `emit`, payload `{ run_id:, event: }` (the event is the string-keyed `{"type" =>, "data" =>, "at" =>}` hash). Fires **live**, even when event *persistence* is buffered.
- `nexo.workflow.status` — on each status transition, payload `{ run_id:, status: }`.

The payloads carry only what `emit`/the run already hold — **no payload or credential dumps**. Subscribe for logging, metrics, or your own UI:

```ruby
ActiveSupport::Notifications.subscribe("nexo.workflow.event") do |*, payload|
  Rails.logger.info("[run #{payload[:run_id]}] #{payload[:event]["type"]}")
end
```

### Opt-in Turbo mirror

Set `config.broadcast_events = true` (and have turbo-rails present) and the engine subscribes `Nexo::TurboBroadcaster`, which appends each event to a per-run Turbo stream, rendering the overridable partial `app/views/nexo/_event.html.erb`. To show live progress, add to your own page (Nexo ships **no** controllers, routes, or dashboard — the host owns all HTTP + UI):

```erb
<%%= turbo_stream_from "nexo_run_#{@run.id}" %>
<div id="nexo_run_<%%= @run.id %>_events">
  <%%# appended events land here %>
</div>
```

Override the appearance by defining your own `app/views/nexo/_event.html.erb` in the host app — it takes precedence over the engine's default.

```ruby
Nexo.configure { |c| c.broadcast_events = true }   # opt in; requires turbo-rails
```

> **Broadcast reachability.** Broadcasts fire from **wherever the run executes** — under `run_later`, that's the worker process. The cable backend (AnyCable, Solid Cable, Redis) must therefore be reachable from your workers, not just your web dynos. Nexo ships **no cable backend** — broadcasting composes whatever the host configured. Without turbo-rails, `broadcast_events` is a harmless no-op: the notifications still fire, so you can subscribe to them yourself.

---

## Run helpers for a host UI

`Nexo::WorkflowRun` exposes query helpers so a host can build its own runs UI without Nexo dictating controllers or views:

```ruby
Nexo::WorkflowRun::STATUSES  # => %w[pending queued running done failed interrupted suspended]

Nexo::WorkflowRun.queued     # scope: status "queued"
Nexo::WorkflowRun.running    # scope: status "running"
Nexo::WorkflowRun.finished   # scope: status "done" or "failed"
Nexo::WorkflowRun.suspended  # scope: status "suspended" (paused, awaiting resume)

run.queued?  run.running?  run.done?  run.failed?  run.suspended?   # predicates

# Artifact access — content only; serving files stays your
# controller's job (Nexo ships no artifact routes/controllers):
run.artifact("digest.md")          # => {"name" =>, "content" =>, "at" =>} or nil
run.artifact_content("digest.md")  # => "…the body…" or nil
```

Artifact access is **content only**; serving files stays your controller's job — Nexo ships no artifact routes or controllers.

---

## Walkthrough

A controller + Turbo-page host-side walkthrough is in the repo — install the store, define a workflow, enqueue it from a controller, and render live progress:

> [View `examples/rails_usage.md` on GitHub →](https://github.com/maquina-app/nexo/blob/main/examples/rails_usage.md)

A live example also wraps an MCP-backed agent in a workflow and captures the digest as an artifact (Task + `run_agent`):

> [View `examples/inbox_digest_task.rb` on GitHub →](https://github.com/maquina-app/nexo/blob/main/examples/inbox_digest_task.rb)


---

## Next steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="../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">
      Workflows
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      The run primitive run_later executes in the background.
    </p>
  </a>

  <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 and continue a run across processes.
    </p>
  </a>
</div>
