Workflows & Automation
Workflows are the automation engine. Each workflow binding pairs a trigger (when to run) with activities (what to do). The Agent owns the schedule; the workflow owns the procedure.
workflows map inside agent.json. An agent without workflows is valid — it provides only a persona and skills for interactive chat.Activities
Activity fields, execution, results, error handling, budgets.
Triggers
Schedule, heartbeat, event, watch, and manual triggers.
Event System
Event sources, envelopes, matching, emit, system events.
Workflow Binding
Each entry in the workflows map binds activities to a trigger:
{
"workflows": {
"morning-briefing": {
"trigger": { "type": "schedule", "cron": "0 7 * * *" },
"description": "Daily morning briefing",
"activities": [
{
"id": "gather",
"intent": "Gather today's priorities from calendar and email",
"skills": ["@nebo/skills/briefing-writer"],
"steps": ["Check calendar for today", "Scan inbox for urgent items", "Compose briefing"],
"token_budget": { "max": 4096 }
},
{
"id": "deliver",
"intent": "Send the briefing to the user",
"steps": ["Format as concise bullet points", "Post to chat"],
"token_budget": { "max": 1024 }
}
],
"budget": { "total_per_run": 6000 },
"emit": "briefing.ready"
}
}
}Binding Fields
| Field | Type | Required | Description |
|---|---|---|---|
ref | string | no | External workflow qualified name (@org/workflows/name@version). Optional when using inline activities. |
trigger | object | yes | When this workflow runs (see Trigger Types) |
description | string | no | Human-readable description |
inputs | map | no | Default inputs passed to the workflow on trigger |
activities | array | no | Inline activity definitions. When present, the workflow runs inline — no external ref needed. |
budget | object | no | Token budget constraints (total_per_run) |
emit | string | no | Event name to emit on completion. By convention, prefix with the agent slug to avoid cross-agent collisions. |
Activities
Activities define the steps an agent takes when a workflow fires. Each activity is an autonomous LLM task with full tool access. Activities execute sequentially — each activity's output becomes context for the next.
Activity Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique activity ID within the binding |
intent | string | yes | Task description — what the LLM should accomplish |
steps | string[] | no | Step-by-step hints for the LLM |
skills | string[] | no | Skill references available to this activity (@org/skills/name) |
mcps | string[] | no | MCP server slugs available to this activity |
model | string | no | Model override ("sonnet", "haiku", "opus") |
token_budget | object | no | Per-activity token limit: { "max": 4096 } |
on_error | object | no | Error handling: { "retry": 1, "fallback": "skip" } |
min_iterations | number | no | Force LLM to continue even if it wants to stop early |
How Activities Execute
- System prompt is constructed from: execution rules, skills, available tools, intent, steps, inputs, and prior activity results
- Agentic loop runs (up to 50 iterations): LLM streams, tool calls, results, repeat
- Context chaining: Each activity's output is appended as
[Activity 'id' result]: textand passed to the next activity - Branch termination: If an activity produces empty output, its branch stops — no downstream activities execute. This follows n8n-style semantics where empty output signals "nothing to do."
- Completion: Activity ends when LLM produces text without tool calls (or budget exhausted)
Activity Results
Each completed activity records:
| Field | Type | Description |
|---|---|---|
started_at | timestamp | When the activity began executing |
completed_at | timestamp | When the activity finished |
input_tokens | number | Token usage for input (prompt) |
output_tokens | number | Token usage for output (completion) |
error_pattern | string | Error pattern tracked for circuit breaker (if failed) |
failure_reason | string | Human-readable failure reason (if failed) |
Built-in Tools
Two tools are always available inside activities:
| Tool | Purpose |
|---|---|
emit | Emit an event into the EventBus. Other agents can subscribe to it. |
exit | Stop the workflow early with a reason (clean termination, not failure). |
Error Handling
| Field | Type | Description |
|---|---|---|
on_error.retry | number | Times to retry the activity before giving up (default: 0) |
on_error.fallback | string | What to do after retries exhausted: skip, abort, notify_owner. Defaults to notify_owner. |
Circuit breaker (CIRCUIT_BREAKER_THRESHOLD = 3): If 3 or more consecutive activities fail with the same error pattern, the workflow aborts automatically. This prevents infinite retry loops on systematic errors.
Same-tool loop detection: If the LLM calls the same tool 3+ times in a row, a steering hint is injected to break the loop.
Cancellation
Workflows support graceful cancellation via a cancellation token. When cancelled:
- The currently in-progress activity is allowed to finish (no mid-activity abort)
- No subsequent activities are started
- The workflow run is marked as cancelled, not failed
Cancel a running workflow via POST /agents/{id}/workflows/{binding}/cancel.
Budget Math
When using inline activities with a budget.total_per_run, the sum of all activity token_budget.max values must not exceed total_per_run. This is validated at parse time — a mismatch prevents the agent from loading.
Activity 1: gather → 4,096 tokens
Activity 2: deliver → 1,024 tokens
─────
Sum: 5,120 tokens
budget.total_per_run: 6,000 tokens (>= sum, headroom for retries)Both per-activity and global budgets are enforced independently at runtime.
Trigger Types
| Type | Fields | Description |
|---|---|---|
schedule | cron | Fires on a cron schedule (standard 5-field expression) |
heartbeat | interval, window | Fires at a recurring interval, optionally limited to a time window |
event | sources | Fires when a matching event occurs |
watch | plugin, event, command | Long-running plugin process that emits NDJSON events |
manual | — | Only fires by explicit user request or API call |
Schedule
{ "type": "schedule", "cron": "0 7 * * 1-5" }Standard 5-field cron. Evaluated every 60 seconds against the agent's configured timezone.
Heartbeat
{ "type": "heartbeat", "interval": "30m", "window": "08:00-18:00" }| Field | Type | Required | Description |
|---|---|---|---|
interval | string | yes | Duration string: "30m", "1h", "2h30m" |
window | string | no | Active hours ("HH:MM-HH:MM"). Outside the window, heartbeats are skipped. |
Event
{ "type": "event", "sources": ["email.urgent", "calendar.*"] }| Field | Type | Description |
|---|---|---|
sources | string[] | Event source patterns to match. Supports exact (email.urgent) and wildcard (email.*). |
An empty sources array is valid JSON but the trigger will never fire — always include at least one source.
Watch
{
"type": "watch",
"plugin": "gws",
"event": "email.new",
"command": "gmail +watch --format ndjson",
"restart_delay_secs": 5
}| Field | Type | Required | Description |
|---|---|---|---|
plugin | string | yes | Plugin slug |
event | string | no | Plugin event name. Enables auto-emission into EventBus. |
command | string | no | CLI args appended to plugin binary. Required if event is not set. |
restart_delay_secs | number | no | Seconds to wait before restarting on crash (default: 5) |
How it works:
- Spawns
<plugin-binary> <command>as a long-running subprocess - Plugin outputs NDJSON (one JSON object per line) to stdout
- Each line triggers the bound activities with the parsed JSON as
_watch_payloadinput - If
eventis set, each line also auto-emits into the EventBus as{plugin}.{event} - On crash, restarts after
restart_delay_secswith exponential backoff (max 300s)
command fallback alongside event. If the plugin manifest doesn't declare the event, resolution fails silently. command ensures the watcher starts regardless.Template Substitution in Commands
Watch trigger commands support {{ key }} placeholders, substituted from the agent's input values at runtime:
"command": "gmail +watch --format ndjson --project {{gcp_project}}"The placeholder name must exactly match an input key. Unmatched placeholders are left as literal text (which will cause failures).
All trigger configs — not just watch commands — support {{ key }} template substitution. Values are resolved from agents.input_values, which users set via the Settings UI or the API. This lets end users customize workflow timing and parameters without editing the agent package.
Manual
{ "type": "manual" }Only fires via API call (POST /agents/{id}/workflows/{binding}/run) or explicit user request.
Event System
Event triggers let workflows react to things that happen — emails arriving, workflows completing, platform changes detected.
Event Sources
| Source | Mechanism | Example Values |
|---|---|---|
| emit | Activity calls emit tool | email.customer-service, lead.qualified |
| watch | Watch trigger auto-emits | gws.email.new, gws.calendar.changed |
| platform | Platform capabilities | calendar.changed, email.received |
| system | Nebo lifecycle | workflow.{id}.completed, workflow.{id}.failed |
Event Envelope
{
"source": "email.customer-service",
"payload": { "from": "j@example.com", "subject": "Order issue" },
"origin": "workflow:email-triage:run-550e8400",
"timestamp": 1709740800
}The payload becomes the triggered workflow's inputs (available as _event_payload).
Source Matching
| Pattern | Matches |
|---|---|
email.urgent | Exact match only |
email.* | Any event starting with email. |
Emit Namespace
Events emitted from activities use the source name as-is — there is no automatic namespace prefix. Activities must include the full source name to avoid collisions across agents.
emit(source: "executive-assistant.briefing.ready", payload: {...})Convention: Prefix event sources with the agent slug to prevent cross-agent collisions. Other agents subscribe to the full source name.
Each emit call is a discrete event. If an activity emits 5 events, each fires independently against all active subscriptions. This enables fan-out pipelines.
System Events
| Event | When |
|---|---|
workflow.{id}.completed | A workflow run finishes successfully |
workflow.{id}.failed | A workflow run fails |
System events enable workflow chaining: A completes, then B triggers.
Progress Events
| Event | When | Payload |
|---|---|---|
ActivityStarted | Each activity begins executing | Activity ID, workflow run ID |
TaskUpdated | During activity execution | Progress details, current activity state |
These events are broadcast over WebSocket, enabling the UI to show real-time workflow progress.
Event-Only Watches
A watch with event set but no activities is valid. It relays plugin output into the EventBus without processing anything inline:
{
"email-relay": {
"trigger": {
"type": "watch",
"plugin": "gws",
"event": "email.new",
"command": "gmail +watch --format ndjson"
},
"description": "Relay new email events into the EventBus"
}
}Other agents subscribe:
{
"handle-emails": {
"trigger": { "type": "event", "sources": ["gws.email.new"] },
"activities": [...]
}
}Example: Email Triage Pipeline
{
"workflows": {
"email-watcher": {
"trigger": {
"type": "watch",
"plugin": "gws",
"event": "email.new",
"command": "gmail +watch --format ndjson"
},
"description": "React to new emails in real-time",
"activities": [
{
"id": "triage",
"intent": "Classify the incoming email and route it",
"steps": [
"Read the email content from _watch_payload",
"Classify as: customer-service, sales-inquiry, or ignore",
"If not ignore, emit the appropriate event with email data"
],
"token_budget": { "max": 2048 }
}
],
"budget": { "total_per_run": 2048 }
},
"handle-cs": {
"trigger": { "type": "event", "sources": ["email.customer-service"] },
"description": "Handle customer service emails",
"activities": [
{
"id": "respond",
"intent": "Draft a helpful response to the customer service email",
"skills": ["@acme/skills/cs-templates"],
"token_budget": { "max": 3000 }
}
],
"budget": { "total_per_run": 3000 }
},
"handle-sales": {
"trigger": { "type": "event", "sources": ["email.sales-inquiry"] },
"description": "Handle inbound sales inquiries",
"activities": [
{
"id": "qualify",
"intent": "Qualify the lead and add to CRM",
"skills": ["@acme/skills/lead-qualification"],
"token_budget": { "max": 3000 }
}
],
"budget": { "total_per_run": 3000 }
}
}
}Read top to bottom: watcher monitors Gmail. For each email, triage classifies and emits. Each emission triggers the appropriate handler with the email data as inputs.
Validation Rules
- Each binding must have a
trigger(required) - Either
reforactivitiesmust be present (one defines what runs) - Trigger
typemust be one of:schedule,heartbeat,event,watch,folder,manual - Lenient parsing: Individual workflow bindings that fail to parse are skipped with a warning — they do not prevent the agent from loading
- Schedule triggers must have a valid 5-field
cronexpression - Heartbeat triggers must have a valid
interval - Event triggers should have at least one entry in
sources - Watch triggers must have a
pluginand eithereventorcommand - Activity IDs must be unique within each binding
- If
budget.total_per_run > 0, the sum of all activitytoken_budget.maxmust not exceed it - All
{{ key }}placeholders in commands must match an inputkeyexactly - An Agent with no workflows is valid (chat-only + persona + skills)