Skip to content

Cliq SDK

Every phase in a cliq pipeline executes through an agent — a lightweight process that reads context from .cliq/, does work (via a CLI tool, an LLM API, or a human review server), writes results back, and exits. The agent handles all protocol mechanics; the implementation does the creative work.

The implementation never touches .cliq/ — only the agent wrapper does.

For most users, the built-in agents are all you need. If you want a phase powered by a different LLM or custom logic, the @cliqhub/cliq-sdk package lets you build your own agent in under 50 lines.


Cliq ships five built-in agents. They are always available and need no configuration.

NameBinaryDescription
cursoragentCursor IDE agent (default)
claude-codeclaudeAnthropic Claude Code
geminigeminiGoogle Gemini CLI
codexcodexOpenAI Codex CLI
hugHuman-in-the-loop review gate
AgentInstall
CursorInstall Cursor desktop app (includes the agent CLI)
Claude Codenpm install -g @anthropic-ai/claude-code
Gemininpm install -g @anthropic-ai/gemini-cli or see Gemini CLI docs
Codexnpm install -g @openai/codex

Run cliq doctor to check which agent binaries are available on your system.


Set the default agent for all projects in ~/.cliqrc/settings.json:

{
"agents": {
"default": "claude-code"
}
}

If omitted, the default is cursor.

Any phase in team.yml can specify a different agent:

workflow:
phases:
- name: research
type: standard
agent: gemini
- name: implementation
type: standard
# uses the default (cursor or whatever agents.default is set to)

Override the default for a single run:

Terminal window
cliq run --agent claude-code

This sets agents.default to claude-code for this invocation only. Phases with an explicit agent in team.yml are not affected.

MethodScopeWhereExample
Global defaultAll projects, all phases~/.cliqrc/settings.json"agents": { "default": "gemini" }
Per-phaseSingle phase in one teamteam.ymlphases: [{ name: research, agent: gemini }]
CLI flagSingle run, all default phasesCommand linecliq run --agent claude-code

Teams can bundle custom agent implementations — for example, an OpenAI-powered analyst or a specialized gate. Custom agents are declared in the agents section of team.yml:

agents:
openai-analyst:
entry: agents/analyst/index.js
env:
- OPENAI_API_KEY
FieldTypeRequiredDescription
entrystringyesPath to the agent entry point, relative to the team directory
envstring[]noEnvironment variable names the agent requires

Built-in agents (cursor, claude-code, gemini, codex, hug) don’t need declarations — they’re available by name.

Users can also define or override agents without modifying a team, via ~/.cliqrc/settings.json:

{
"agents": {
"custom": {
"my-reviewer": {
"entry": "/absolute/path/to/my_reviewer.js",
"env": ["OPENAI_API_KEY"]
}
}
}
}

See Settings — agents for the full reference.


Terminal window
npm install @cliqhub/cliq-sdk

Requires Node.js 18+.

If you’re adding an agent to a team package:

Terminal window
mkdir -p agents/my-agent
cd agents/my-agent
npm init -y
npm install @cliqhub/cliq-sdk

Use the SDK when:

  • You want a phase powered by a different LLM API (OpenAI, Anthropic, Google, Mistral, etc.)
  • You need custom logic beyond what a CLI agent provides (multi-step reasoning, tool orchestration, database access, external API calls)
  • You want to wrap an existing service or internal tool as a pipeline phase
  • You need a custom gate with domain-specific review logic

For simply switching between supported CLI agents (Cursor, Claude Code, Gemini, Codex), use the agent field in team.yml — no SDK needed.


A standard phase agent does work and produces output for downstream phases.

agents/analyst/index.ts
import { StandardPhaseAgent } from '@cliqhub/cliq-sdk';
new StandardPhaseAgent({
name: 'openai-analyst',
execute: async (ctx) => {
ctx.progress('Starting analysis...');
const response = await call_your_llm(ctx.role, ctx.inputs);
for (const target of ctx.targets) {
ctx.send(target, response.text);
}
},
}).run();

Declare it in team.yml:

name: "@local/research-team"
agents:
openai-analyst:
entry: agents/analyst/index.ts
env:
- OPENAI_API_KEY
workflow:
phases:
- name: research
type: standard
- name: analysis
type: standard
agent: openai-analyst
depends_on: [research]
- name: review
type: gate
depends_on: [analysis]

Run:

Terminal window
cliq assemble research-team
cliq run

The orchestrator writes a dispatch file, spawns your agent in a tmux pane, and the SDK handles everything else — reading context, calling your execute, writing channels and signals.


A gate agent reviews work and renders a verdict: pass, route back, or escalate.

agents/reviewer/index.ts
import { GatePhaseAgent } from '@cliqhub/cliq-sdk';
new GatePhaseAgent({
name: 'strict-reviewer',
execute: async (ctx) => {
const all_pass = ctx.command_results.every(r => r.pass);
if (!all_pass) {
const failed = ctx.command_results
.filter(r => !r.pass)
.map(r => r.name);
return {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: `Checks failed: ${failed.join(', ')}`,
};
}
const review = await call_llm_for_review(ctx.role, ctx.inputs);
if (review.approved) {
return {
verdict: { outcome: 'PASS' },
comment: review.reason,
};
}
return {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: review.reason,
};
},
}).run();

Gate verdicts are type-safe discriminated unions:

VerdictShapeMeaning
Pass{ outcome: 'PASS' }All good — pipeline continues forward
Route{ outcome: 'ROUTE', target: '<phase>' }Send back to a specific phase for rework
Escalate{ outcome: 'ESCALATE' }Requires human intervention — pipeline pauses

The SDK validates ROUTE targets at runtime. If the target is not in ctx.route_targets, the agent fails with a clear error.


The execute function is the core of your agent. It receives a context object and returns a result. The SDK assembles the context from .cliq/ protocol files before calling your function, and writes all results back to .cliq/ after it returns.

FieldTypeDescription
phasestringPhase name (e.g. "analysis")
rolestringFull role brief from .cliq/roles/<phase>.md
inputsRecord<string, string>Input channel contents from predecessor phases
req_specstringRequirements specification from .cliq/req_spec.md
task_boardTaskBoardStructured task board interface (see TaskBoard API below)
design_docsRecord<string, string>Design documents, keyed by filename
settingsobjectResolved settings from .cliq/resolved_settings.json
targetsstring[]Downstream phase names this phase can send handoff notes to
send(target, message)functionSend a handoff message to a downstream phase. Writes to disk immediately
project_dirstringAbsolute path to the project root
metadata.instance_idstringPipeline run instance ID
metadata.team_namestringTeam package name
metadata.req_keystringRequirement key (e.g. Jira ticket)
progress(msg)functionLog a status message (visible in the tmux pane)

Gate agents receive everything in PhaseContext plus:

FieldTypeDescription
command_resultsCommandResult[]Results of automated commands (name, pass, output, exit_code)
route_targetsstring[]Valid phases this gate can route work back to
reviewReviewBlock | undefinedReview configuration (present only on hug phases): reviewer, artifacts, timeout, remind_every

Standard phase agents return void. All output is written imperatively during execution:

  • Channelsctx.send(target, message) writes handoff content to disk immediately
  • Task boardctx.task_board.mark_complete(), .add_note(), etc.

This means partially completed work survives agent crashes — whatever was sent before the failure is preserved on disk.

FieldTypeRequiredDescription
verdictVerdictOutcomeyes{ outcome: 'PASS' }, { outcome: 'ROUTE', target: '...' }, or { outcome: 'ESCALATE' }
commentstringyesExplanation for the verdict
reviewerstringnoWho made the decision
decided_atstringnoISO timestamp of the decision

import { StandardPhaseAgent } from '@cliqhub/cliq-sdk';
import OpenAI from 'openai';
const client = new OpenAI();
new StandardPhaseAgent({
name: 'openai-researcher',
execute: async (ctx) => {
const input_text = Object.values(ctx.inputs).join('\n\n---\n\n');
const completion = await client.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: ctx.role },
{ role: 'user', content: input_text || ctx.req_spec },
],
});
const content = completion.choices[0].message.content ?? '';
for (const target of ctx.targets) {
ctx.send(target, content);
}
},
}).run();
import { StandardPhaseAgent } from '@cliqhub/cliq-sdk';
new StandardPhaseAgent({
name: 'tool-agent',
execute: async (ctx) => {
let context = build_initial_context(ctx.role, ctx.inputs);
let iterations = 0;
while (iterations < 10) {
ctx.progress(`Iteration ${iterations + 1}...`);
const response = await call_llm(context);
if (response.tool_calls) {
const results = await execute_tools(response.tool_calls);
context = append_tool_results(context, results);
iterations++;
continue;
}
for (const target of ctx.targets) {
ctx.send(target, response.text);
}
return;
}
},
}).run();
import { GatePhaseAgent } from '@cliqhub/cliq-sdk';
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
new GatePhaseAgent({
name: 'claude-reviewer',
execute: async (ctx) => {
const failed_commands = ctx.command_results.filter(r => !r.pass);
if (failed_commands.length > 0) {
return {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: `${failed_commands.length} command(s) failed: ${failed_commands.map(c => c.name).join(', ')}`,
};
}
const artifacts = Object.values(ctx.inputs).join('\n\n---\n\n');
const msg = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 2048,
system: ctx.role,
messages: [{
role: 'user',
content: `Review the following work:\n\n${artifacts}\n\nRespond with PASS if the work meets all requirements, or ROUTE if it needs changes. Explain your reasoning.`,
}],
});
const text = msg.content[0].type === 'text' ? msg.content[0].text : '';
const is_pass = text.toUpperCase().includes('PASS');
if (is_pass) {
return {
verdict: { outcome: 'PASS' },
comment: text,
decided_at: new Date().toISOString(),
};
}
return {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: text,
decided_at: new Date().toISOString(),
};
},
}).run();

The ctx.task_board object exposes structured methods — agents never manipulate the markdown format directly.

import { StandardPhaseAgent } from '@cliqhub/cliq-sdk';
new StandardPhaseAgent({
name: 'task-tracker',
execute: async (ctx) => {
ctx.progress('Working on tasks...');
const result = await do_work(ctx);
ctx.task_board.mark_complete(ctx.phase);
ctx.task_board.add_note(ctx.phase, `Completed: ${result.summary}`);
for (const target of ctx.targets) {
ctx.send(target, result.text);
}
},
}).run();

See TaskBoard API for the full interface.


Declare required environment variables in team.yml:

agents:
openai-analyst:
entry: agents/analyst/index.js
env:
- OPENAI_API_KEY

These must be set in the user’s shell before cliq run. The env field documents requirements but does not set values.

Shared environment variables for all agents can be set in settings:

{
"agents": {
"env": {
"OPENAI_API_KEY": "sk-..."
}
}
}

The SDK classes are designed for testability. Use execute_lifecycle(phase_name) in tests instead of run() (which parses process.argv and calls process.exit).

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { StandardPhaseAgent } from '@cliqhub/cliq-sdk';
describe('my analyst agent', () => {
let project_dir: string;
beforeEach(() => {
project_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
process.env.CLIQ_PROJECT_DIR = project_dir;
const cliq = (...p: string[]) => path.join(project_dir, '.cliq', ...p);
fs.mkdirSync(cliq('signals'), { recursive: true });
fs.mkdirSync(cliq('roles'), { recursive: true });
fs.mkdirSync(cliq('channels'), { recursive: true });
fs.writeFileSync(cliq('roles', 'analyst.md'), 'You are an analyst.');
fs.writeFileSync(cliq('signals', 'analyst_dispatch.json'), JSON.stringify({
phase: 'analyst',
mode: 'phase',
project_dir,
instance_id: 'test-1',
team_name: 'test',
req_key: 'TEST-1',
output_channels: ['analyst--reviewer'],
}));
});
afterEach(() => {
fs.rmSync(project_dir, { recursive: true, force: true });
delete process.env.CLIQ_PROJECT_DIR;
});
it('produces output on the expected channel', async () => {
const agent = new StandardPhaseAgent({
name: 'openai-analyst',
execute: async (ctx) => ({
output: { 'analyst--reviewer': 'Analysis results here.' },
summary: 'Done.',
}),
});
await agent.execute_lifecycle('analyst');
const cliq = (...p: string[]) => path.join(project_dir, '.cliq', ...p);
const handoff = fs.readFileSync(
cliq('channels', 'analyst--reviewer', 'handoff.md'), 'utf8',
);
expect(handoff).toBe('Analysis results here.');
expect(fs.existsSync(cliq('signals', 'analyst_done'))).toBe(true);
});
});

Test your agent against a real .cliq/ structure:

Terminal window
# 1. Set up minimal protocol files
mkdir -p .cliq/signals .cliq/roles .cliq/channels/researcher--analyst .cliq/design
echo "You are a research analyst." > .cliq/roles/analyst.md
echo "Research findings." > .cliq/channels/researcher--analyst/handoff.md
echo "Analyze the data." > .cliq/req_spec.md
echo '{}' > .cliq/resolved_settings.json
# 2. Write a dispatch file
cat > .cliq/signals/analyst_dispatch.json << 'EOF'
{
"phase": "analyst",
"mode": "phase",
"project_dir": "/path/to/your/project",
"instance_id": "test-1",
"team_name": "test",
"req_key": "TEST-1",
"output_channels": ["analyst--reviewer"]
}
EOF
# 3. Run the agent
node agents/analyst/index.js analyst
# 4. Verify results
cat .cliq/channels/analyst--reviewer/handoff.md
test -f .cliq/signals/analyst_done && echo "Done signal written"

import { StandardPhaseAgent } from '@cliqhub/cliq-sdk';
new StandardPhaseAgent({
name: string,
execute: (ctx: PhaseContext) => Promise<void>,
}).run();

Creates a standard phase agent. Call .run() to start the CLI lifecycle (reads phase name from process.argv[2], runs, exits). Use .execute_lifecycle(phase_name) in tests.

import { GatePhaseAgent } from '@cliqhub/cliq-sdk';
new GatePhaseAgent({
name: string,
execute: (ctx: GateContext) => Promise<GateResult>,
}).run();

Creates a gate phase agent. Same lifecycle as StandardPhaseAgent, but writes a _verdict signal instead of _done.

type VerdictOutcome =
| { outcome: 'PASS' }
| { outcome: 'ROUTE'; target: string }
| { outcome: 'ESCALATE' };

Type-safe discriminated union for gate verdicts. The SDK validates ROUTE targets against ctx.route_targets at runtime.

The ctx.task_board object provides structured methods for updating the task board. Agents never manipulate the internal markdown format directly — the SDK handles serialization and only writes .cliq/task_board.md when mutations have been made.

Method / PropertyDescription
mark_complete(phase)Toggle - [ ] phase- [x] phase. No-op if already complete or not found.
mark_incomplete(phase)Toggle - [x] phase- [ ] phase. No-op if already incomplete or not found.
add_note(phase, note)Insert a note line below the matching phase entry. No-op if phase not found.
replace(content)Replace the entire board with new content (for LLM-generated rewrites).
contentRead-only string — current board content (pass to an LLM as context).
dirtyRead-only boolean — true if any mutation has been made.
execute: async (ctx) => {
ctx.task_board.mark_complete(ctx.phase);
ctx.task_board.add_note(ctx.phase, 'OAuth2 PKCE flow implemented');
for (const target of ctx.targets) {
ctx.send(target, 'OAuth2 PKCE flow implemented.');
}
},

The SDK writes the task board to disk automatically after execute() returns — only when ctx.task_board.dirty is true.


When .run() is called, the SDK:

  1. Reads the phase name from process.argv[2]
  2. Reads the dispatch file from .cliq/signals/<phase>_dispatch.json
  3. Reads all protocol files: role, channels, req_spec, task_board, design docs, settings
  4. Constructs the typed context (PhaseContext or GateContext) with a TaskBoard instance
  5. Calls your execute(ctx) function — channel writes happen immediately via ctx.send() during execution
  6. Writes .cliq/task_board.md if ctx.task_board was mutated (dirty === true)
  7. Standard phases: writes .cliq/signals/<phase>_done
  8. Gate phases: writes .cliq/signals/<phase>_verdict (JSON)
  9. Exits with code 0

No long-running server, no HTTP, no polling. The process starts, does its work, writes results, and exits.

If your execute function throws, the SDK:

  1. Writes the error message to .cliq/signals/<phase>_error
  2. Does not write _done or _verdict
  3. Exits with code 1

The orchestrator detects the missing signal and escalates. You do not need to wrap your execute() in a try/catch for protocol safety. However, you may want your own try/catch for retries or graceful degradation.


A team with a custom agent:

my-team/
├── team.yml
├── roles/
│ ├── research.md
│ └── analysis.md
├── agents/
│ └── analyst/
│ ├── index.ts
│ └── package.json
└── README.md

After cliq assemble:

.cliq/
├── workflow.yml
├── agents.json # generated manifest
├── roles/
│ ├── research.md
│ └── analysis.md
├── agents/
│ └── analyst/
│ ├── index.ts
│ └── package.json
├── channels/ # created at runtime
└── signals/ # created at runtime

cliq doctor checks agent binaries based on your configuration:

  • With a project (.cliq/ exists): reads the agent manifest and checks every built-in CLI agent’s underlying binary
  • Without a project: checks only your default agent from ~/.cliqrc/settings.json
Checking dependencies...
tmux... ✓ (tmux 3.3a)
node... ✓ (v20.11.0)
git... ✓ (git version 2.43.0)
Agents:
agent... ✓ (Cursor 0.45.0)

FieldTypeDefaultDescription
agents.defaultstring"cursor"Default agent for phases without an explicit agent key
agents.customRecord<string, AgentConfig>{}User-defined agent configurations, keyed by name
agents.envRecord<string, string>{}Environment variables injected into every agent process

See Settings for the full settings reference.


Agent process exits with code 1 — Your execute() threw. Check .cliq/signals/{phase}_error for the message. Common causes: missing API key, network timeout, undefined variable.

Empty or missing channels — If you don’t call ctx.send() for each target, downstream phases won’t find the expected handoff files. Use ctx.targets to see which phases expect output. Calling ctx.send() with an invalid target throws immediately.

Dispatch file not found — The agent can’t find .cliq/signals/{phase}_dispatch.json. Check that the orchestrator wrote it (see orchestrator logs) and that CLIQ_PROJECT_DIR or the working directory is correct.

ROUTE target validation error — The GatePhaseAgent validates that ROUTE targets exist in ctx.route_targets. If your gate returns { outcome: 'ROUTE', target: 'nonexistent' }, the SDK throws and writes an error signal. Check your workflow’s depends_on graph — route targets are derived from predecessor phases.

Missing environment variable — If your custom agent requires an API key, set it in your shell before running cliq run. The env field in team.yml documents which variables are needed but does not set them.

Terminal window
export OPENAI_API_KEY=sk-...
cliq run

Agent binary not found — A built-in agent’s underlying CLI binary must be on your PATH. Run cliq doctor to see which are missing and get install hints.