Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions .agents/skills/workflow-creator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,16 @@ Workflow quality depends on two disciplines: **prompt engineering** (crafting cl
A workflow is a TypeScript file with a single `.run()` callback that orchestrates agent sessions dynamically. Inside the callback, `ctx.stage()` spawns sessions — each gets its own tmux window and graph node (unless running in headless mode). Native TypeScript handles all control flow: loops, conditionals, `Promise.all()`, `try`/`catch`.

```ts
import { defineWorkflow } from "@bastani/atomic/workflows";

export default defineWorkflow<"claude">({ name: "my-workflow", description: "..." })
import { defineWorkflow, extractAssistantText } from "@bastani/atomic/workflows";

export default defineWorkflow({
name: "my-workflow",
description: "...",
inputs: [
{ name: "prompt", type: "text", required: true, description: "task to perform" },
],
})
.for<"claude">()
.run(async (ctx) => {
const step1 = await ctx.stage({ name: "step-1" }, {}, {}, async (s) => { /* s.client, s.session */ });
await ctx.stage({ name: "step-2" }, {}, {}, async (s) => { /* s.client, s.session */ });
Expand All @@ -121,7 +128,7 @@ await ctx.stage(
async (s) => {
const result = await s.session.query("Analyze the codebase structure.");
s.save(s.sessionId);
return result.output;
return extractAssistantText(result, 0);
},
);
```
Expand Down Expand Up @@ -182,23 +189,23 @@ Workflow files live at `.atomic/workflows/<name>/<agent>/index.ts`. Discovery so
| `WorkflowContext` (`ctx`) | `.run(async (ctx) => ...)` | No | Orchestration: spawn sessions, read transcripts, read `ctx.inputs` |
| `SessionContext` (`s`) | `ctx.stage(opts, clientOpts, sessionOpts, async (s) => ...)` | Yes | Agent work: use `s.client` and `s.session` for SDK calls, save output |

Both contexts expose `inputs: Record<string, string>`, `stage()`, `transcript()`, and `getMessages()`. See `references/getting-started.md` for the full `SessionContext` field reference.
Both contexts expose typed `inputs` (keys restricted to declared input names), `stage()`, `transcript()`, and `getMessages()`. See `references/getting-started.md` for the full `SessionContext` field reference.

### Declared inputs: one API, three invocation surfaces

Workflows receive user data exclusively through `ctx.inputs` (and `s.inputs` inside stage callbacks). You have two choices:
Workflows receive user data exclusively through `ctx.inputs` (and `s.inputs` inside stage callbacks).

**Free-form** — no schema. The positional CLI prompt lands under `ctx.inputs.prompt`. Read via `ctx.inputs.prompt ?? ""`.
Declare `inputs: WorkflowInput[]` inline on `defineWorkflow()`. TypeScript infers literal field names from the array and restricts `ctx.inputs` to only those keys — accessing an undeclared field is a **compile-time error**. The CLI materializes one `--<field>=<value>` flag per entry, validates required fields + enum membership before launching, and the picker renders a form. Three field types: `string` (single-line), `text` (multi-line), `enum` (fixed set).

**Structured** — declare `inputs: WorkflowInput[]` on `defineWorkflow`. The CLI materializes one `--<field>=<value>` flag per entry, validates required fields + enum membership before launching, and the picker renders a form. Three field types: `string` (single-line), `text` (multi-line), `enum` (fixed set).
Workflows that accept a free-form prompt should declare it explicitly: `{ name: "prompt", type: "text", required: true }`.

**Load `references/workflow-inputs.md`** for the full schema shape, validation rules, free-form-vs-structured decision guide, picker semantics, and invocation cheat sheet.
**Load `references/workflow-inputs.md`** for the full schema shape, validation rules, picker semantics, and invocation cheat sheet.

### Invocation surfaces

| Surface | Command | When |
|---|---|---|
| Named, free-form | `atomic workflow -n hello -a claude "fix the bug"` | Scripted runs; prompt lands in `ctx.inputs.prompt` |
| Named, with prompt | `atomic workflow -n hello -a claude "fix the bug"` | Scripted runs; requires the workflow to declare a `prompt` input |
| Named, structured | `atomic workflow -n gen-spec -a claude --research_doc=notes.md` | Scripted structured runs |
| Interactive picker | `atomic workflow -a claude` | Discovery; shows fuzzy list + form |
| List | `atomic workflow -l` | Browse everything by source |
Expand Down Expand Up @@ -274,13 +281,13 @@ Then apply **design advisory checks** — these catch architectural and prompt q

### 2. Choose the Target Agent

Pass a type parameter to `defineWorkflow<"agent">()` to narrow all context types and get correct `s.client`/`s.session` types:
Use `.for<"agent">()` on the builder to narrow all context types and get correct `s.client`/`s.session` types. Call `.for()` **before** `.run()`:

| Agent | Type Parameter | Primary Session API |
| Agent | Builder Chain | Primary Session API |
|-------|---------------|---------------------|
| Claude | `defineWorkflow<"claude">` | `s.session.query(prompt)` — sends prompt to the Claude TUI pane |
| Copilot | `defineWorkflow<"copilot">` | `s.session.send({ prompt })` — fire-and-forget; use `sendAndWait({ prompt }, timeoutMs)` only when the user explicitly requests timeout-based waiting |
| OpenCode | `defineWorkflow<"opencode">` | `s.client.session.prompt({ sessionID: s.session.id, parts: [...] })` |
| Claude | `defineWorkflow({...}).for<"claude">()` | `s.session.query(prompt)` — sends prompt to the Claude TUI pane |
| Copilot | `defineWorkflow({...}).for<"copilot">()` | `s.session.send({ prompt })` — fire-and-forget; use `sendAndWait({ prompt }, timeoutMs)` only when the user explicitly requests timeout-based waiting |
| OpenCode | `defineWorkflow({...}).for<"opencode">()` | `s.client.session.prompt({ sessionID: s.session.id, parts: [...] })` |

The runtime manages client/session lifecycle automatically. For native SDK types and advanced APIs, import directly from the provider packages (`@github/copilot-sdk`, `@anthropic-ai/claude-agent-sdk`, `@opencode-ai/sdk/v2`).

Expand Down Expand Up @@ -309,7 +316,7 @@ Per-SDK cheat sheet:
| Save output | `s.save(s.sessionId)` | `s.save(await s.session.getMessages())` | `s.save(result.data!)` |
| Timeout | Per-query defaults via sessionOpts | N/A (`send` has no timeout; `sendAndWait` accepts optional timeout, default 60s) | N/A |
| Context model | Tmux pane (accumulates across turns) | Fresh per `ctx.stage()` | Fresh per `ctx.stage()` |
| Extract text | `result.output` (string) | `getAssistantText(messages)` (see `failure-modes.md` F1) | `extractResponseText(result.data!.parts)` (see `failure-modes.md` F3) |
| Extract text | `extractAssistantText(result, 0)` (uses `SessionMessage[]`) | `getAssistantText(messages)` (see `failure-modes.md` F1) | `extractResponseText(result.data!.parts)` (see `failure-modes.md` F3) |

The SDK ships two builtin workflows as production reference implementations:
- **`ralph`** — iterative plan → orchestrate → review → debug loop (all 3 SDKs)
Expand All @@ -326,7 +333,7 @@ bun typecheck
### 5. Test the Workflow

```bash
# Free-form workflow
# Workflow with a declared prompt input
atomic workflow -n <workflow-name> -a <agent> "<your prompt>"

# Structured workflow
Expand Down
91 changes: 67 additions & 24 deletions .agents/skills/workflow-creator/references/agent-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import { defineWorkflow } from "@bastani/atomic/workflows";
await ctx.stage(
{ name: "implement", description: "Implement the feature" },
{}, // clientOpts: chatFlags and readyTimeoutMs go here
{}, // sessionOpts: query defaults (timeoutMs, pollIntervalMs, etc.) go here
{}, // sessionOpts: query defaults (pollIntervalMs, readyTimeoutMs, etc.) go here
async (s) => {
// s.client — Claude CLI wrapper (already started by runtime)
// s.session — session wrapper (ready to accept queries via s.session.query())

// Send queries — Claude maintains conversation context across calls
// Returns SessionMessage[] (native SDK type from @anthropic-ai/claude-agent-sdk)
const result = await s.session.query((s.inputs.prompt ?? ""));
// result.output contains the captured response text

// Save transcript
s.save(s.sessionId);
Expand All @@ -44,34 +44,40 @@ Client options (2nd arg to `ctx.stage()`):
- `readyTimeoutMs` — timeout waiting for TUI readiness (default: 30s)

Session options (3rd arg to `ctx.stage()`), applied as defaults to every `s.session.query()` call:
- `timeoutMs` — timeout waiting for Claude to finish responding (default: 300s)
- `pollIntervalMs` — polling interval (default: 2000ms)
- `submitPresses` — C-m presses per submit round (default: 1)
- `maxSubmitRounds` — max submit rounds (default: 6)
- `readyTimeoutMs` — timeout waiting for pane readiness before sending (default: 30s)

No manual timeout is needed — idle detection watches for the pane prompt to return, and the session transcript is used to extract the response text.

### Basic usage with `s.session.query()`

```ts
import { defineWorkflow } from "@bastani/atomic/workflows";

export default defineWorkflow<"claude">({ name: "implement" })
export default defineWorkflow({
name: "implement",
inputs: [{ name: "prompt", type: "text", required: true, description: "task prompt" }],
})
.for<"claude">()
.run(async (ctx) => {
await ctx.stage(
{ name: "implement", description: "Implement the feature" },
{},
{},
async (s) => {
const result = await s.session.query((s.inputs.prompt ?? ""));
// result.output contains the captured response text
const messages = await s.session.query((s.inputs.prompt ?? ""));
// messages is SessionMessage[] — native SDK type
// Use extractAssistantText(messages, 0) to get the text response
s.save(s.sessionId);
},
);
})
.compile();
```

`s.session.query(prompt)` sends text to the Claude pane, verifies delivery, retries if needed, and waits for output stabilization. Returns `{ output: string }`.
`s.session.query(prompt)` sends text to the Claude pane, verifies delivery, retries if needed, and waits for output stabilization. Returns `SessionMessage[]` (the native transcript messages from this turn, imported from `@anthropic-ai/claude-agent-sdk`). Use `extractAssistantText(messages, 0)` to extract the plain text response.

### Multi-turn conversations

Expand Down Expand Up @@ -183,43 +189,72 @@ const result = query({ prompt: "Continue...", options: { resume: sessionId } });
const result = query({ prompt: "Try a different approach", options: { resume: sessionId, forkSession: true } });
```

### Sub-agent delegation via `s.session.query()`
### Sub-agent delegation

For stages that call a single sub-agent, use `--agent` (interactive) or the SDK `agent` option (headless) to route all prompts through that agent. The agent must be defined in `.claude/agents/` or `.agents/skills/`.

Invoke named sub-agents by prefixing the prompt with `@"agent-name (agent)"`. The agent must be defined in `.claude/agents/`:
**Interactive stages** — pass `--agent` via `chatFlags` in client opts (2nd arg):

```ts
.run(async (ctx) => {
await ctx.stage({ name: "plan-and-implement" }, {}, {}, async (s) => {
// Delegate to the "planner" agent
await s.session.query(`@"planner (agent)" Create a plan for: ${(s.inputs.prompt ?? "")}`);
await ctx.stage(
{ name: "plan" },
{ chatFlags: ["--agent", "planner", "--allow-dangerously-skip-permissions", "--dangerously-skip-permissions"] },
{},
async (s) => {
await s.session.query(`Create a plan for: ${(s.inputs.prompt ?? "")}`);
s.save(s.sessionId);
},
);
})
```

// Delegate to the "orchestrator" agent
await s.session.query(`@"orchestrator (agent)" Execute the plan above.`);
**Headless stages** — pass `agent` via SDK options in the `query()` call:

s.save(s.sessionId);
});
```ts
.run(async (ctx) => {
const handle = await ctx.stage(
{ name: "locate", headless: true },
{}, {},
async (s) => {
const result = await s.session.query(
"Find all API endpoint files",
{ agent: "codebase-locator", permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true },
);
s.save(s.sessionId);
return extractAssistantText(result, 0);
},
);
})
```

> **Note:** The `@"agent-name (agent)"` prompt prefix is for multi-agent conversations in a single stage where you switch between agents mid-session. For single-agent stages, prefer `--agent` (interactive) or the `agent` SDK option (headless) as shown above.

### Headless mode (background stages)

Claude headless stages use the Agent SDK's `query()` API directly in-process instead of automating a tmux pane. Set `headless: true` in the stage options:
Claude headless stages use the Agent SDK's `query()` API directly in-process instead of automating a tmux pane. Set `headless: true` in the stage options. SDK options like `agent`, `permissionMode`, and `allowDangerouslySkipPermissions` can be passed directly in the `query()` call:

```ts
import { defineWorkflow, extractAssistantText } from "@bastani/atomic/workflows";

// ...
await ctx.stage(
{ name: "background-analysis", headless: true },
{}, {},
async (s) => {
// s.session.query() works identically — the runtime uses
// HeadlessClaudeSessionWrapper which calls the Agent SDK directly
const result = await s.session.query("Analyze the codebase.");
const result = await s.session.query(
"Analyze the codebase.",
{ agent: "codebase-analyzer", permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true },
);
s.save(s.sessionId);
return result.output;
return extractAssistantText(result, 0);
},
);
```

The callback interface is identical to interactive stages. Internally, the runtime uses `HeadlessClaudeClientWrapper` (no-op start/stop) and `HeadlessClaudeSessionWrapper` (calls `query()` from `@anthropic-ai/claude-agent-sdk` directly). No tmux pane is created, and the stage is invisible in the workflow graph.
The callback interface is identical to interactive stages — `s.session.query()` returns `SessionMessage[]` in both cases. Internally, the runtime uses `HeadlessClaudeSessionWrapper` which calls `query()` from `@anthropic-ai/claude-agent-sdk` directly. No tmux pane is created, and the stage is invisible in the workflow graph.

**Design principle:** Never create custom message types. All provider return types are native SDK types — `SessionMessage[]` for Claude, `SessionEvent[]` for Copilot, `SessionPromptResponse` for OpenCode. Use `extractAssistantText()` to extract plain text from Claude's `SessionMessage[]`.

## Copilot SDK

Expand All @@ -230,7 +265,11 @@ Copilot uses a client-server architecture. The runtime auto-creates a `CopilotCl
```ts
import { defineWorkflow } from "@bastani/atomic/workflows";

export default defineWorkflow<"copilot">({ name: "implement" })
export default defineWorkflow({
name: "implement",
inputs: [{ name: "prompt", type: "text", required: true, description: "task prompt" }],
})
.for<"copilot">()
.run(async (ctx) => {
await ctx.stage(
{ name: "implement" },
Expand Down Expand Up @@ -563,7 +602,11 @@ OpenCode uses a client-server model. The runtime auto-creates an `OpencodeClient
```ts
import { defineWorkflow } from "@bastani/atomic/workflows";

export default defineWorkflow<"opencode">({ name: "implement" })
export default defineWorkflow({
name: "implement",
inputs: [{ name: "prompt", type: "text", required: true, description: "task prompt" }],
})
.for<"opencode">()
.run(async (ctx) => {
await ctx.stage(
{ name: "implement" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ Each SDK returns responses in different formats. Use helpers to extract text:

### Claude

`s.session.query()` returns `{ output: string, delivered: boolean }` — the captured response text.
`s.session.query()` returns `SessionMessage[]` — the native SDK transcript messages from this turn. Use `extractAssistantText()` to extract the plain text:

```ts
import { extractAssistantText } from "@bastani/atomic/workflows";

const result = await s.session.query("...");
const text = result.output; // Already a string
const text = extractAssistantText(result, 0); // Extract text from SessionMessage[]
```

### Copilot
Expand Down Expand Up @@ -212,7 +214,7 @@ ${implTranscript.content}
Respond with JSON: { "correctness": N, "completeness": N, "style": N, "pass": boolean, "issues": [...] }`,
);

const scores = parseJsonResponse(result.output);
const scores = parseJsonResponse(extractAssistantText(result, 0));

if (!scores.pass) {
await s.session.query(`Fix these quality issues:\n${scores.issues.join("\n")}`);
Expand Down
Loading
Loading