Skip to content

Agent SDK Reference

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 22+ built-in agents — CLI agents, LLM API agents, connector agents, and special-purpose agents. See the Agent Reference for the full catalog with per-agent documentation, setup guides, and handoff structures.

CLI agents (cursor, claude-code, gemini, codex) require their respective binary installed. All other agents only need settings configured via cliq settings. Run cliq doctor to check availability.


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

{
"default_agent": "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 default_agent is set to)

Override the default for a single run:

Terminal window
cliq run --agent claude-code

This sets default_agent 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"default_agent": "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
FieldTypeRequiredDescription
entrystringyesPath to the agent entry point, relative to the team directory
envstring[]noEnvironment variable names the agent requires from the host OS

All 22 built-in agents are available by name without declarations.

Users configure agent settings (API keys, credentials) in ~/.cliqrc/settings.json under agents.<name>:

{
"agents": {
"openai-analyst": {
"api_key": "sk-..."
}
}
}

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 { CliqAgent, build_phase_context, finalize_standard_phase } from '@cliqhub/cliq-sdk';
new CliqAgent({
name: 'openai-analyst',
handlers: {
'phase:standard': async (msg) => {
const ctx = build_phase_context(msg, 'openai-analyst');
ctx.progress('Starting analysis...');
const response = await call_your_llm(ctx.role, ctx.input_text);
for (const target of ctx.targets) {
ctx.send(target, response.text);
}
finalize_standard_phase(ctx, msg);
},
},
}).run();

Declare it in team.yml:

name: "@local/research-team"
agents:
openai-analyst:
entry: agents/analyst/index.ts
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 builds an AgentMessage JSON envelope, pipes it to your agent via stdin, and the SDK handles everything else — parsing the message, assembling context, calling your handler, writing channels, and signaling completion via the JSONL signal channel.


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

agents/reviewer/index.ts
import { CliqAgent, GateContext, build_phase_context, finalize_gate_phase } from '@cliqhub/cliq-sdk';
new CliqAgent({
name: 'strict-reviewer',
handlers: {
'phase:gate': async (msg) => {
const ctx = build_phase_context(msg, 'strict-reviewer') as GateContext;
const all_pass = ctx.check_results.every(r => r.pass);
if (!all_pass) {
const failed = ctx.check_results
.filter(r => !r.pass)
.map(r => r.name);
const result = {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: `Checks failed: ${failed.join(', ')}`,
};
finalize_gate_phase(ctx, result, msg);
return;
}
const review = await call_llm_for_review(ctx.role, ctx.inputs);
if (review.approved) {
const result = {
verdict: { outcome: 'PASS' },
comment: review.reason,
};
finalize_gate_phase(ctx, result, msg);
return;
}
const result = {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: review.reason,
};
finalize_gate_phase(ctx, result, msg);
},
},
}).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 handler function is the core of your agent. Each handler receives an AgentMessage and uses build_phase_context(msg, name) to get a typed context object. The context object fields remain the same — build_phase_context assembles the context from the message’s project_dir, reading .cliq/ protocol files, and returns a PhaseContext or GateContext depending on the phase type. After your handler completes its work, call finalize_standard_phase(ctx, msg) or finalize_gate_phase(ctx, result, msg) to write channels and signal completion.

The AgentMessage is the JSON envelope piped to every agent invocation via stdin. It contains everything the agent needs — identity, paths, settings, and a type-specific payload.

import { AgentMessage } from '@cliqhub/cliq-sdk';
FieldTypeDescription
versionnumberProtocol version (currently 1)
typestringMessage type — determines routing. Values: "phase", "notify", "test". For "phase", the handler key is derived from payload.phase_type (e.g. "phase:standard" or "phase:gate")
agentstringAgent name (e.g. "openai-analyst")
instance_idstringPipeline run instance ID
team_namestringTeam package name
req_keystringRequirement key (e.g. Jira ticket)
project_dirstringAbsolute path to the project root
work_dirstringProject workspace root — where agents operate on files
signal_dirstringDirectory where the JSONL signal channel (<phase>.jsonl) is written
handoff_dirsstring[]Absolute paths to output channel directories — one per downstream phase in the DAG
input_artifactsArtifact[]Input artifacts — specs, upstream handoffs, inline content. Each artifact has name, type ("file" / "uri" / "inline"), and source (a path, URL, or literal content)
settingsRecord<string, unknown>Agent-specific settings from settings.json, filtered to declared keys
payloadRecord<string, unknown>Type-specific payload — phase config for phase:*, notification data for notify, empty for test

Every invocation has a signal_dir where the SDK appends messages to a JSONL signal channel (<phase>.jsonl). All messages conform to the ChannelMessage interface:

import { ChannelMessage } from '@cliqhub/cliq-sdk';
FieldTypeDescription
tsstringISO timestamp
version1Protocol version
typestringDispatch type: "phase", "notify", or "test"
statusstring"ok", "complete", "error", "progress", or "notify"
resultunknown?Handler return value. Present when status is "complete". For gates, contains { verdict: { outcome, target?, reason } }
errorstring?Error message when status is "error"
messagestring?Human-readable progress note when status is "progress"

The SDK writes these automatically — you don’t write channel messages yourself. The signal lifecycle is:

  1. Ack{status: "ok"} appended after parsing the message. The orchestrator uses this to confirm the agent started.
  2. Progress (optional) — {status: "progress", message: "..."} appended during execution via ctx.progress().
  3. Notify (optional) — {status: "notify", result: {...}} appended when the agent registers a notification dispatch.
  4. Completion{status: "complete", result: {...}} or {status: "error", error: "..."} appended when the handler finishes. For gates, the result field carries the verdict.

The channel abstraction uses FileChannelWriter (JSONL append) in the SDK and FileChannelReader in the orchestrator, both implementing transport-agnostic interfaces for future migration to localhost HTTP channels.

FieldTypeDescription
phasestringPhase name (e.g. "analysis")
rolestringFull role brief from .cliq/roles/<phase>.md
is_supportboolean?Whether this phase is a support phase activated by gate routing
input_envelopesRecord<string, HandoffEnvelope>Raw handoff envelopes from predecessor phases, keyed by channel name (e.g. "architect--developer"). Each envelope has version, phase_type, phase, agent, data?, and text? fields. Use this when you need structured access to upstream data.
input_textRecord<string, string>Pre-rendered, prompt-ready text from predecessor handoffs, keyed by channel name. The SDK automatically renders envelopes into a format suitable for LLM consumption — including both structured data (as formatted JSON) and natural language text. For most agents, this is the only input field you need.
dispatchobjectAgent-specific configuration from the phase YAML. Contains action, sources, target_entries, commands, model, and review — only the fields relevant to your agent type are populated. See Dispatch Fields below.
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
workflow_inputsRecord<string, string>?Resolved team inputs from .cliq/inputs.json, available to all phases
targetsstring[]Downstream phase names this phase can send handoff notes to
send(target, payload)functionSend a handoff to a downstream phase. Accepts a string or { data?, text? } object. Writes handoff.json to disk immediately
write_file(name, content)functionWrite a file to .cliq/files/{phase}/{name}. Used by connector agents to persist fetched data or generated output to the workspace
settingsRecord<string, unknown>Agent-specific settings from settings.json, resolved from the agent’s manifest settings.required / settings.optional declarations. Contains API keys, service URLs, credentials, and any other configuration the agent declared
project_dirstringAbsolute path to the project root
work_dirstringProject workspace root — where agents operate on files
state_dirstringOrchestration state directory (call frame) — where signals/channels live
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)
signal(name, payload)functionWrite a named signal ({phase}_{name}.json) to .cliq/signals/
register_notification(event, channel_ref, payload)functionRegister a notification for inline dispatch via the JSONL channel. event is the trigger name (e.g. hug_review), channel_ref is the delivery target in agent:channel format (e.g. slack:oncall, email:ops), and payload is a free-form object. Writes a {status: "notify"} message to the signal channel; the orchestrator dispatches it to the named agent

The ctx.dispatch object carries phase YAML configuration that varies by agent type. Phase-specific fields live under ctx.dispatch.phase_config:

const action = ctx.dispatch.phase_config.action as string; // connector action string
const sources = ctx.dispatch.phase_config.sources as SourceEntry[]; // SourceEntry[]
const model = ctx.dispatch.phase_config.model as string; // LLM model override
FieldTypeDescription
actionstring?Connector action (e.g. get_issue, search). Set from phase YAML
sourcesSourceEntry[]?Declared data sources to fetch (see SourceEntry)
target_entriesTargetEntry[]?Declared data targets to deliver (see TargetEntry)
commandsCommand[]?Shell commands for exec agents (see Command)
modelstring?LLM model override from phase YAML
reviewReviewBlock?Review configuration for hug gates: reviewer, artifacts, timeout, remind_every

GateContext extends PhaseContext — gate agents have access to all the same fields (input_envelopes, input_text, dispatch, req_spec, task_board, etc.) plus these gate-specific additions:

FieldTypeDescription
check_resultsGateCheckResult[]Results of automated checks (name, pass, output, exit_code)
route_targetsstring[]Valid phases this gate can route work back to
iterationnumberCurrent gate iteration (1-based)
max_iterationsnumberMaximum gate loop iterations before auto-escalation

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

  • Channelsctx.send(target, payload) writes a handoff.json envelope 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.

ctx.send() accepts either a string (shorthand for { text: string }) or a structured payload:

// String shorthand — sets the text field
ctx.send('reviewer', 'Implementation complete. All tests pass.');
// Structured payload — sets both data and text
ctx.send('reviewer', {
data: { files_changed: 3, test_count: 12, coverage: 94.2 },
text: 'Implementation complete. 12 tests pass with 94.2% coverage.',
});

The data field is accessible downstream via $(handoff.<phase>.<path>) template variables. See Template Variables for details.

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 { CliqAgent, build_phase_context, finalize_standard_phase } from '@cliqhub/cliq-sdk';
import OpenAI from 'openai';
const client = new OpenAI();
new CliqAgent({
name: 'openai-researcher',
handlers: {
'phase:standard': async (msg) => {
const ctx = build_phase_context(msg, 'openai-researcher');
const completion = await client.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: ctx.role },
{ role: 'user', content: ctx.input_text || ctx.req_spec },
],
});
const content = completion.choices[0].message.content ?? '';
for (const target of ctx.targets) {
ctx.send(target, content);
}
finalize_standard_phase(ctx, msg);
},
},
}).run();
import { CliqAgent, build_phase_context, finalize_standard_phase } from '@cliqhub/cliq-sdk';
new CliqAgent({
name: 'tool-agent',
handlers: {
'phase:standard': async (msg) => {
const ctx = build_phase_context(msg, 'tool-agent');
let context = build_initial_context(ctx.role, ctx.input_text);
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);
}
finalize_standard_phase(ctx, msg);
return;
}
},
},
}).run();
import { CliqAgent, GateContext, build_phase_context, finalize_gate_phase } from '@cliqhub/cliq-sdk';
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
new CliqAgent({
name: 'claude-reviewer',
handlers: {
'phase:gate': async (msg) => {
const ctx = build_phase_context(msg, 'claude-reviewer') as GateContext;
const failed_checks = ctx.check_results.filter(r => !r.pass);
if (failed_checks.length > 0) {
const result = {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: `${failed_checks.length} check(s) failed: ${failed_checks.map(c => c.name).join(', ')}`,
};
finalize_gate_phase(ctx, result, msg);
return;
}
const llm_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${ctx.input_text}\n\nRespond with PASS if the work meets all requirements, or ROUTE if it needs changes. Explain your reasoning.`,
}],
});
const text = llm_msg.content[0].type === 'text' ? llm_msg.content[0].text : '';
const is_pass = text.toUpperCase().includes('PASS');
if (is_pass) {
const result = {
verdict: { outcome: 'PASS' },
comment: text,
decided_at: new Date().toISOString(),
};
finalize_gate_phase(ctx, result, msg);
return;
}
const result = {
verdict: { outcome: 'ROUTE', target: ctx.route_targets[0] },
comment: text,
decided_at: new Date().toISOString(),
};
finalize_gate_phase(ctx, result, msg);
},
},
}).run();

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

import { CliqAgent, build_phase_context, finalize_standard_phase } from '@cliqhub/cliq-sdk';
new CliqAgent({
name: 'task-tracker',
handlers: {
'phase:standard': async (msg) => {
const ctx = build_phase_context(msg, 'task-tracker');
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);
}
finalize_standard_phase(ctx, msg);
},
},
}).run();

See TaskBoard API for the full interface.


Agents receive their configuration through ctx.settings — a Record<string, unknown> containing only the keys declared in the agent’s manifest. The orchestrator reads settings from settings.json (agents.<name>.*), filters them to the declared keys, and passes them to the agent at dispatch time.

handlers: {
'phase:standard': async (msg) => {
const ctx = build_phase_context(msg, 'my-agent');
const api_key = ctx.settings.api_key as string;
// Use the key to call an API, set process.env, etc.
},
},

Built-in agents declare their required settings in their manifest.json under settings.required. You don’t need to list them in team.ymlcliq doctor and cliq assemble read manifests automatically.

Set per-agent settings in settings.json under agents.<name>:

{
"agents": {
"cursor": {
"api_key": "key_abc123..."
},
"claude-code": {
"api_key": "sk-ant-..."
}
}
}

Use cliq setup to configure these interactively, or set them with cliq settings:

Terminal window
cliq settings agents.cursor.api_key key_abc123...

Custom agents declare required settings in their manifest.json under settings.required. They are registered in team.yml under agents: and resolved via agents.json:

{
"name": "openai-analyst",
"version": "1.0.0",
"entry": "./index.js",
"capabilities": ["phase:standard"],
"settings": { "required": ["api_key"] }
}

Set the values in settings the same way:

{
"agents": {
"openai-analyst": {
"api_key": "sk-..."
}
}
}

If your agent wraps a CLI binary that expects specific environment variables, declare them in the manifest’s env array. These are host OS environment variables the agent expects to inherit — separate from cliq-managed settings:

{
"env": ["PATH", "HOME"]
}

The agent is responsible for mapping ctx.settings values to process.env if its internal library requires it.


The CliqAgent class is designed for testability. Use agent.dispatch(msg) in tests instead of run() (which reads stdin and calls process.exit). Build an AgentMessage with the make_message() helper or construct one manually.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
CliqAgent,
AgentMessage,
build_phase_context,
finalize_standard_phase,
} from '@cliqhub/cliq-sdk';
describe('my analyst agent', () => {
let project_dir: string;
let signal_dir: string;
beforeEach(() => {
project_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
signal_dir = path.join(project_dir, '.cliq', 'signals', 'analyst');
const cliq = (...p: string[]) => path.join(project_dir, '.cliq', ...p);
fs.mkdirSync(signal_dir, { recursive: true });
fs.mkdirSync(cliq('roles'), { recursive: true });
fs.mkdirSync(cliq('channels'), { recursive: true });
fs.writeFileSync(cliq('roles', 'analyst.md'), 'You are an analyst.');
});
afterEach(() => {
fs.rmSync(project_dir, { recursive: true, force: true });
});
it('produces output on the expected channel', async () => {
const msg: AgentMessage = {
version: 1,
type: 'phase',
agent: 'openai-analyst',
instance_id: 'test-1',
team_name: 'test',
req_key: 'TEST-1',
project_dir,
work_dir: project_dir,
signal_dir,
handoff_dirs: [path.join(project_dir, '.cliq', 'channels', 'analyst--reviewer')],
input_artifacts: [],
settings: {},
payload: { phase_type: 'standard', phase: 'analyst' },
};
const agent = new CliqAgent({
name: 'openai-analyst',
handlers: {
'phase:standard': async (m) => {
const ctx = build_phase_context(m, 'openai-analyst');
for (const target of ctx.targets) {
ctx.send(target, 'Analysis results here.');
}
finalize_standard_phase(ctx, m);
},
},
});
await agent.dispatch(msg);
const cliq = (...p: string[]) => path.join(project_dir, '.cliq', ...p);
const envelope = JSON.parse(fs.readFileSync(
cliq('channels', 'analyst--reviewer', 'handoff.json'), 'utf8',
));
expect(envelope.text).toBe('Analysis results here.');
expect(envelope.phase_type).toBe('standard');
const channel_path = path.join(signal_dir, 'analyst.jsonl');
const lines = fs.readFileSync(channel_path, 'utf8').trim().split('\n');
const last = JSON.parse(lines[lines.length - 1]);
expect(last.status).toBe('complete');
});
});

Test your agent by piping an AgentMessage JSON to stdin:

Terminal window
# 1. Set up minimal protocol files
mkdir -p .cliq/signals .cliq/roles .cliq/channels/researcher--analyst .cliq/channels/analyst--reviewer .cliq/design
echo "You are a research analyst." > .cliq/roles/analyst.md
echo '{"version":1,"phase_type":"standard","phase":"researcher","agent":"exec","text":"Research findings."}' > .cliq/channels/researcher--analyst/handoff.json
echo "Analyze the data." > .cliq/req_spec.md
# 2. Build an AgentMessage and pipe it to the agent
cat << 'EOF' | node agents/analyst/index.js
{
"version": 1,
"type": "phase",
"agent": "openai-analyst",
"instance_id": "test-1",
"team_name": "test",
"req_key": "TEST-1",
"project_dir": "/path/to/your/project",
"work_dir": "/path/to/your/project",
"signal_dir": "/path/to/your/project/.cliq/signals",
"handoff_dirs": ["/path/to/your/project/.cliq/channels/analyst--reviewer"],
"input_artifacts": [],
"settings": {},
"payload": { "phase_type": "standard", "phase": "analyst" }
}
EOF
# 3. Verify results
cat .cliq/channels/analyst--reviewer/handoff.json
cat .cliq/signals/analyst.jsonl

import { CliqAgent, AgentMessage } from '@cliqhub/cliq-sdk';
new CliqAgent({
name: string,
handlers: {
'phase:standard'?: (msg: AgentMessage) => Promise<unknown>,
'phase:gate'?: (msg: AgentMessage) => Promise<unknown>,
'notify'?: (msg: AgentMessage) => Promise<unknown>,
'test'?: (msg: AgentMessage) => Promise<unknown>,
},
}).run();
FieldTypeRequiredDescription
namestringyesAgent name (e.g. "openai-analyst")
handlersobjectyesMap of handler functions keyed by message type. Provide one or more handler keys
handlers['phase:standard']functionnoStandard phase handler — receives an AgentMessage, uses build_phase_context(msg, name) to get a PhaseContext. Call finalize_standard_phase(ctx, msg) when done
handlers['phase:gate']functionnoGate phase handler — receives an AgentMessage, uses build_phase_context(msg, name) to get a GateContext. Call finalize_gate_phase(ctx, result, msg) with the verdict to write the signal channel and the handoff envelope
handlers['notify']functionnoNotification handler — receives an AgentMessage with notification payload. Invoked when the orchestrator dispatches the agent to deliver a pipeline event notification (e.g. on_complete, on_escalate)
handlers['test']functionnoConnectivity test — receives an AgentMessage and returns a TestResult. Invoked by cliq doctor agent <name> to verify the agent can reach its external dependencies. A default handler is provided if not specified

An agent can implement one or more handlers. The orchestrator dispatches to the correct handler based on the AgentMessage.type field.

Call .run() to start the CLI lifecycle (reads AgentMessage JSON from stdin, dispatches, exits). Use .dispatch(msg) in tests.

Agent requirements (environment variables, binary dependencies, etc.) are declared in the accompanying manifest.json — not in the constructor. See manifest.json below.

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.

Every agent ships a manifest.json that declares its requirements, capabilities, and metadata. This is the file cliq doctor, cliq assemble, and cliq setup read to validate that all agent prerequisites are met before a run starts.

{
"name": "my-agent",
"version": "1.0.0",
"description": "What the agent does",
"entry": "./agent.js",
"capabilities": ["phase:standard", "phase:gate"],
"role": "required",
"binaries": ["git"],
"settings": {
"required": ["api_key"],
"optional": ["model"]
},
"env": ["PATH"],
"schema": {},
"output": { "data": null, "text": "Description of output" }
}
FieldTypeRequiredDescription
namestringyesAgent name — must match the name passed to CliqAgent
versionstringyesSemver version string
min_cliq_versionstringnoMinimum cliq version required to run this agent
descriptionstringyesHuman-readable description of what the agent does
entrystringyesPath to the agent entry point, relative to the manifest directory
capabilitiesstring[]yesMessage types the agent supports: "phase:standard", "phase:gate", "notify", or any combination
rolestringnoAgent role hint (e.g. "required", "optional")
settingsobjectnoSettings requirements — keys the agent reads from ctx.settings
settings.requiredstring[]noSettings keys the agent requires (validated at assemble/doctor time)
settings.optionalstring[]noSettings keys the agent can use but doesn’t require
binariesstring[]noSystem binaries the agent requires (e.g. ["git"]). cliq doctor checks each binary is on PATH
envstring[]noHost environment variables the agent expects to inherit from the OS
schemaobjectyesSchema for the agent’s phase YAML configuration — a Record<string, SchemaField> declaring each field’s type, required, and optional enum, default, description
outputobjectnoDescription of the agent’s output shape
output.dataanynoSchema or example of structured data output
output.textstringnoDescription of text output

Place manifest.json alongside your agent entry point. Custom agents registered in team.yml under agents: are resolved via agents.json, which is generated by cliq assemble.

Declared data source on a phase — used by connector agents to fetch external data.

interface SourceEntry {
name: string;
ref?: string; // system-specific reference (doc ID, S3 path, issue key, etc.)
url?: string; // HTTP URL (for curl agent)
headers?: Record<string, string>;
method?: string; // HTTP method override (default: GET, or POST when body is present)
body?: string; // request body (for curl agent). When present, method defaults to POST
[key: string]: unknown; // additional connector-specific fields
}

Declared data target on a phase — used by connector agents to deliver output to external systems.

interface TargetEntry {
name: string;
file: string; // local file to deliver
ref?: string; // system-specific destination
url?: string; // HTTP URL (for curl agent)
headers?: Record<string, string>;
method?: string; // HTTP method override (default: POST)
mode?: 'create' | 'append' | 'replace';
}

Shell command definition for exec agents.

interface Command {
name: string;
run: string;
escalate_on_fail?: boolean;
}

Result of a single automated check in a gate phase.

interface GateCheckResult {
name: string;
pass: boolean;
output: string;
exit_code?: number;
}

Agents can implement a 'test' handler to let cliq doctor verify connectivity to external services. When a user runs cliq doctor agent <name>, the SDK dispatches an AgentMessage with type: 'test' and the agent’s settings. The handler returns a TestResult.

new CliqAgent({
name: 'my-api-agent',
handlers: {
'phase:standard': async (msg) => { /* ... */ },
'test': async (msg) => {
const api_key = msg.settings.api_key as string;
if (!api_key) {
return { passed: false, message: 'Missing api_key in settings' };
}
const resp = await fetch('https://api.example.com/ping', {
headers: { Authorization: `Bearer ${api_key}` },
});
if (!resp.ok) {
return { passed: false, message: `API returned ${resp.status}` };
}
return { passed: true, message: 'Connected to example API' };
},
},
}).run();

If a 'test' handler is not provided, the SDK uses a default handler that returns a pass result.

The 'test' handler receives an AgentMessage with type: 'test'. Agent settings are available at msg.settings.

FieldTypeDescription
passedbooleanWhether the connectivity test passed
messagestringHuman-readable description of the result

The 'notify' handler receives an AgentMessage with type: 'notify'. The notification payload is available via msg.payload, which contains event, channel, and event-specific data. This happens when a pipeline event (configured in notifications.* settings) triggers a notification delivery.

The msg.payload for notify messages conforms to NotifyPayload:

FieldTypeDescription
notificationsNotification[]Batch of notification events targeting this agent

Each Notification in the array has:

FieldTypeDescription
eventstringPipeline event name (e.g. on_complete, on_escalate, hug_review)
channelstring | nullChannel within the agent (e.g. "alerts", "ops"), or null for the default channel
phase_namestring?Phase that triggered the event
reasonstring?Human-readable reason for the event
outcomestring?Pipeline outcome at event time
[key]unknownAdditional event-specific data (varies by event type)
import { CliqAgent, AgentMessage, NotifyPayload } from '@cliqhub/cliq-sdk';
new CliqAgent({
name: 'slack',
handlers: {
'phase:standard': async (msg) => { /* ... */ },
'notify': async (msg) => {
const payload = msg.payload as NotifyPayload;
const channels = msg.settings.channels as Record<string, { webhook_url: string }>;
for (const notification of payload.notifications) {
const channel_key = notification.channel ?? 'default';
const webhook_url = channels[channel_key]?.webhook_url;
if (!webhook_url) continue;
await fetch(webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `Pipeline event: ${notification.event}`,
blocks: format_blocks(notification),
}),
});
}
},
},
}).run();

Agents that implement the 'notify' handler (like slack and email) are automatically eligible for notification dispatch. The orchestrator matches the channel prefix (e.g. email:ops resolves to the email agent with channel ops; bare slack resolves to the slack agent with channel default).

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.
handlers: {
'phase:standard': async (msg) => {
const ctx = build_phase_context(msg, 'my-agent');
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.');
}
finalize_standard_phase(ctx, msg);
},
},

The SDK writes the task board to disk automatically when finalize_standard_phase is called — only when ctx.task_board.dirty is true.


When .run() is called, the SDK:

  1. Reads AgentMessage JSON from stdin
  2. Parses and validates the message (version, type, required fields, signal_dir)
  3. Appends {status: "ok"} to the JSONL signal channel
  4. Resolves the handler key from the message type field
  5. Calls the matched handler with the message
  6. Appends {status: "complete"} or {status: "error"} to the signal channel
  7. Exits with code 0 (success) or 1 (error)

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

If your handler throws, the SDK:

  1. Appends {status: "error", error: "<message>"} to the signal channel
  2. Exits with code 1

The orchestrator reads the channel and escalates on error. You do not need to wrap your handler 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/ # handoff envelopes between phases
├── files/ # fetched/generated files, organized by phase
│ └── {phase}/
│ └── {name}
└── signals/ # orchestrator coordination files

cliq doctor reads each agent’s manifest.json to verify prerequisites:

  • Binary check: reads the manifest’s entry point and verifies the agent is resolvable
  • System binaries: reads manifest.binaries and verifies each binary is available on PATH
  • Settings check: reads settings.required from the manifest and verifies the keys are present in agents.<name> settings
  • Connectivity test: if the agent implements test(), runs it to verify the agent can reach its external dependencies
  • With a project (.cliq/ exists): checks every agent referenced in agents.json
  • 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)
api_key... ✓

FieldTypeDefaultDescription
default_agentstring"cursor"Default agent for phases without an explicit agent key
agents.<name>.entrystringCustom entry point for the agent
agents.<name>.api_keystringAgent API key (CLI and LLM API agents)
agents.<name>.*unknownArbitrary agent-specific settings (e.g. base_url, email, credentials_file)

Agents receive their settings at runtime via ctx.settings. See Settings for the full settings reference.


Agent process exits with code 1 — Your handler threw. Check the JSONL signal channel (<phase>.jsonl) in the signal directory for the error 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.

AgentMessage parse error — The agent couldn’t parse the JSON from stdin. Check that the orchestrator is piping a valid AgentMessage envelope. Verify the JSON is well-formed and includes all required fields (version, type, agent, signal_dir).

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

Missing agent setting — If your custom agent requires an API key, configure it in settings.json under agents.<name> before running cliq run. The manifest’s settings.required array documents which keys are needed.

Terminal window
cliq settings agents.openai-analyst.api_key sk-... --global
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.


Connector agents fetch data from external services or deliver results to them. They use msg.payload.action, msg.payload.sources, and msg.payload.target_entries to dispatch operations.

Use msg.payload.action to route different operations within a single agent:

handlers: {
'phase:standard': async (msg: AgentMessage) => {
const ctx = build_phase_context(msg, 'acme-connector');
const action = msg.payload.action as string || 'get';
const sources = msg.payload.sources as SourceEntry[] || [];
const target_entries = msg.payload.target_entries as TargetEntry[] || [];
if (action === 'get' || action === 'list') {
for (const source of sources) {
const data = await fetch_from_api(source, action);
ctx.write_file(source.name, data);
}
finalize_standard_phase(ctx, msg);
return;
}
if (action === 'create' || action === 'update') {
for (const target of target_entries) {
await deliver_to_api(target, ctx, action);
}
finalize_standard_phase(ctx, msg);
return;
}
throw new Error(`Unknown action: ${action}`);
},
},

Different phases set different actions in team.yml:

- name: fetch-issues
agent: acme-connector
action: get
sources:
- name: report.json
ref: "reports/latest"
- name: push-results
agent: acme-connector
action: create
depends_on: [analyst]
target_entries:
- name: result
file: analysis.json
ref: "results/new"

The idiomatic pattern for connector tests is a local HTTP server + agent.dispatch(msg):

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { CliqAgent, build_phase_context, finalize_standard_phase } from '@cliqhub/cliq-sdk';
import type { AgentMessage } from '@cliqhub/cliq-sdk';
describe('acme_connector', () => {
let server: http.Server;
let base_url: string;
let project_dir: string;
let last_path: string;
beforeEach(async () => {
project_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliq-test-'));
server = http.createServer((req, res) => {
last_path = req.url || '';
res.setHeader('content-type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({ status: 'ok', data: [1, 2, 3] }));
});
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as { port: number };
base_url = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
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 });
});
afterEach(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
fs.rmSync(project_dir, { recursive: true, force: true });
});
it('fetches source and writes to workspace', async () => {
const msg: AgentMessage = {
version: 1,
type: 'phase',
agent: 'acme-connector',
instance_id: 'test-1',
team_name: 'test',
req_key: 'TEST-1',
project_dir,
work_dir: project_dir,
signal_dir: path.join(project_dir, '.cliq', 'signals'),
handoff_dirs: [],
input_artifacts: [],
settings: { base_url, api_key: 'test-key' },
payload: {
phase_type: 'standard',
phase: 'fetch-data',
action: 'get',
sources: [{ name: 'report.json', ref: 'reports/latest' }],
},
};
const agent = new CliqAgent({
name: 'acme-connector',
handlers: {
'phase:standard': async (m: AgentMessage) => {
const ctx = build_phase_context(m, 'acme-connector');
const sources = m.payload.sources as { name: string; ref: string }[];
for (const source of sources || []) {
const resp = await fetch(`${m.settings.base_url}/api/${source.ref}`, {
headers: { 'Authorization': `Bearer ${m.settings.api_key}` },
});
ctx.write_file(source.name, await resp.text());
}
finalize_standard_phase(ctx, m);
},
},
});
await agent.dispatch(msg);
expect(last_path).toBe('/api/reports/latest');
});
});

Bundle a custom agent with your team:

  1. Create the agent directory with manifest.json and agent code
  2. Declare it in team.yml under agents:
  3. Reference it in phases with agent: <name>
  4. Set credentials via cliq settings agents.<name>.<key> ...
  5. Validate with cliq doctor agent <name> and cliq team validate
  6. Publish with cliq team publish — the agent directory is bundled with the team package

  • Agent Reference — full agent catalog with handoff structures and configuration