Agent SDK
TypeScript SDK for building custom agents with tx task management
API Server Required
The Agent SDK connects to tx via the REST API server. You must start the API server before using the SDK:
tx serve
# Server running at http://localhost:3456Alternatively, use direct mode with dbPath to skip the API server entirely (requires @jamesaphoenix/tx-core and effect as dependencies).
Installation
npm install @jamesaphoenix/tx-agent-sdkOr with your preferred package manager:
# pnpm
pnpm add @jamesaphoenix/tx-agent-sdk
# yarn
yarn add @jamesaphoenix/tx-agent-sdk
# bun
bun add @jamesaphoenix/tx-agent-sdkQuick Start
import { TxClient } from "@jamesaphoenix/tx-agent-sdk"
// HTTP mode (connects to tx API server)
const tx = new TxClient({ apiUrl: "http://localhost:3456" })
// Get the next ready task
const ready = await tx.tasks.ready({ limit: 1 })
if (ready.length === 0) {
console.log("No tasks ready")
process.exit(0)
}
const task = ready[0]
console.log(`Working on: ${task.title}`)
// Get context (relevant learnings for this task)
const ctx = await tx.context.forTask(task.id)
for (const l of ctx.learnings) {
console.log(`- ${l.content}`)
}
// ... do the work ...
// Mark complete and see what was unblocked
const { nowReady } = await tx.tasks.done(task.id)
console.log(`Unblocked ${nowReady.length} tasks`)
// Record what you learned
await tx.learnings.add({
content: "Use retry logic for flaky network calls",
category: "best-practices",
})Connection Modes
The SDK supports two transport modes:
| Mode | Config | Requires | Best For |
|---|---|---|---|
| HTTP | apiUrl | Running tx serve | Remote agents, distributed systems |
| Direct | dbPath | @jamesaphoenix/tx-core + effect | Local agents, single-machine setups |
// HTTP mode
const tx = new TxClient({ apiUrl: "http://localhost:3456" })
// Direct SQLite mode (no server needed)
const tx = new TxClient({ dbPath: ".tx/tasks.db" })
// With authentication
const tx = new TxClient({
apiUrl: "http://localhost:3456",
apiKey: process.env.TX_API_KEY,
timeout: 60000, // 60 second timeout
})When both apiUrl and dbPath are provided, direct mode takes precedence. Direct mode requires @jamesaphoenix/tx-core and effect as installed dependencies.
HTTP Actor Header
In HTTP mode, TxClient sends x-tx-actor: agent by default. The dashboard sends x-tx-actor: human for interactive task changes. If you call the REST API directly as a human, send x-tx-actor: human on task update and completion requests.
curl -X POST http://localhost:3456/api/tasks/tx-abc123/done \
-H "x-tx-actor: human"If your orchestration loop uses tx gate with a linked taskId, keep that review task human-owned. Let the agent stop at the gate, then have the human approve the gate and close the linked task via tx done <id> --human or a REST call with x-tx-actor: human. See tx gate for the full loop recipes.
API Reference
Tasks
tx.tasks.ready(options?)
Get tasks that are ready to be worked on (all blockers completed). Returns tasks sorted by priority score (descending).
const ready = await tx.tasks.ready({ limit: 5 })
if (ready.length > 0) {
console.log(`Next task: ${ready[0].title}`)
}Options:
| Option | Type | Default | Description |
|---|---|---|---|
limit | number | 100 | Maximum number of tasks to return |
labels | string[] | - | Only include tasks with these labels |
excludeLabels | string[] | - | Exclude tasks with these labels |
tx ready --limit 5 --json{
"tool": "tx_ready",
"arguments": {
"limit": 5
}
}curl http://localhost:3456/api/tasks/ready?limit=5tx.tasks.done(id)
Mark a task as done. Returns the completed task and an array of tasks that became ready as a result.
const { task, nowReady } = await tx.tasks.done("tx-abc123")
console.log(`Completed: ${task.title}`)
console.log(`Unblocked ${nowReady.length} tasks`)tx done tx-abc123{
"tool": "tx_done",
"arguments": {
"taskId": "tx-abc123"
}
}curl -X POST http://localhost:3456/api/tasks/tx-abc123/done \
-H "x-tx-actor: human"tx.tasks.create(data)
Create a new task.
const task = await tx.tasks.create({
title: "Implement auth",
description: "Add JWT-based authentication",
score: 100,
metadata: { component: "auth" },
})Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Task title |
description | string | No | Detailed description |
parentId | string | No | Parent task ID for hierarchy |
score | number | No | Priority score (higher = more urgent) |
metadata | Record<string, unknown> | No | Arbitrary key-value metadata |
tx add "Implement auth" --score 100{
"tool": "tx_add",
"arguments": {
"title": "Implement auth",
"description": "Add JWT-based authentication",
"score": 100
}
}curl -X POST http://localhost:3456/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Implement auth", "description": "Add JWT-based authentication", "score": 100}'tx.tasks.get(id)
Get a task by ID, including full dependency information.
const task = await tx.tasks.get("tx-abc123")
console.log(task.title, task.isReady)
console.log(`Blocked by: ${task.blockedBy.join(", ")}`)
console.log(`Blocks: ${task.blocks.join(", ")}`)
console.log(`Direct group context: ${task.groupContext ?? "(none)"}`)
console.log(`Effective group context: ${task.effectiveGroupContext ?? "(none)"}`)tx.tasks.update(id, data)
Update a task's fields. Only provided fields are changed.
await tx.tasks.update("tx-abc123", {
status: "active",
score: 200,
})tx.tasks.setGroupContext(id, context)
Set direct task-group context on a task. Related ancestors/descendants inherit it through effectiveGroupContext.
await tx.tasks.setGroupContext("tx-root001", "Shared auth rollout context")tx.tasks.clearGroupContext(id)
Clear direct task-group context from a task.
await tx.tasks.clearGroupContext("tx-root001")tx.tasks.delete(id, options?)
Delete a task. Fails if the task has children unless cascade: true is set.
// Delete a single task
await tx.tasks.delete("tx-abc123")
// Delete task and all descendants
await tx.tasks.delete("tx-abc123", { cascade: true })tx.tasks.list(options?)
List tasks with pagination and filtering.
// List all tasks
const all = await tx.tasks.list()
// Filter by status
const active = await tx.tasks.list({ status: "active" })
// Multiple statuses
const working = await tx.tasks.list({ status: ["active", "planning"] })
// Full-text search
const results = await tx.tasks.list({ search: "auth" })
// Paginate
const page1 = await tx.tasks.list({ limit: 10 })
if (page1.hasMore) {
const page2 = await tx.tasks.list({ limit: 10, cursor: page1.nextCursor! })
}Options:
| Option | Type | Default | Description |
|---|---|---|---|
cursor | string | - | Pagination cursor from nextCursor |
limit | number | 20 | Maximum tasks to return |
status | TaskStatus | TaskStatus[] | - | Filter by status |
search | string | - | Full-text search across title and description |
tx.tasks.block(id, blockerId)
Add a blocker dependency. Circular dependencies are rejected.
// "deploy" can't start until "build" is done
await tx.tasks.block("tx-deploy", "tx-build")tx.tasks.unblock(id, blockerId)
Remove a blocker dependency.
await tx.tasks.unblock("tx-deploy", "tx-build")tx.tasks.tree(id)
Get a task and all its descendants as a flat array.
const tree = await tx.tasks.tree("tx-root")
console.log(`${tree.length} tasks in tree`)Learnings
Unified Memory Primitive
The tx.learnings.* and tx.context.* SDK methods map to the unified tx memory primitive. The SDK API names are unchanged for backwards compatibility, but the underlying functionality is part of the memory system. Use tx memory add with -t learning tags at the CLI level for the equivalent workflow.
tx.learnings.search(options?)
Search learnings using BM25 text search. Returns results with relevance scores.
// Search by keyword
const results = await tx.learnings.search({ query: "authentication" })
// Get recent learnings (no query)
const recent = await tx.learnings.search({ limit: 5 })
// Filter by category
const practices = await tx.learnings.search({
query: "error handling",
category: "best-practices",
})Options:
| Option | Type | Default | Description |
|---|---|---|---|
query | string | - | Search query (omit for recent) |
limit | number | 10 | Maximum results |
minScore | number | - | Minimum relevance score (0-1) |
category | string | - | Filter by category |
tx memory search "authentication"{
"tool": "tx_learning_search",
"arguments": {
"query": "authentication",
"limit": 10
}
}curl "http://localhost:3456/api/learnings?query=authentication&limit=10"tx.learnings.add(data)
Create a new learning to persist knowledge for future agents.
await tx.learnings.add({
content: "Use bcrypt for password hashing, not SHA256",
sourceType: "manual",
sourceRef: "tx-abc123",
category: "security",
keywords: ["passwords", "hashing", "bcrypt"],
})Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
content | string | Yes | The learning content |
sourceType | string | No | 'manual', 'run', 'compaction', or 'claude_md' |
sourceRef | string | No | Reference to the source (e.g. task ID) |
category | string | No | Category for filtering |
keywords | string[] | No | Keywords for search indexing |
tx memory add "Use bcrypt for password hashing, not SHA256"{
"tool": "tx_learning_add",
"arguments": {
"content": "Use bcrypt for password hashing, not SHA256",
"category": "security"
}
}curl -X POST http://localhost:3456/api/learnings \
-H "Content-Type: application/json" \
-d '{"content": "Use bcrypt for password hashing, not SHA256", "category": "security"}'tx.learnings.get(id)
Get a learning by its numeric ID.
const learning = await tx.learnings.get(42)
console.log(learning.content)tx.learnings.helpful(id, score?)
Record that a learning was helpful, boosting its outcome score. Higher outcome scores cause learnings to rank higher in future searches.
await tx.learnings.helpful(42)
await tx.learnings.helpful(42, 0.8) // partial helpfulnessContext
tx.context.forTask(taskId)
Get contextual learnings for a task. Uses the task's title and description to find relevant learnings. This is the primary mechanism for injecting memory into agent prompts.
const ctx = await tx.context.forTask("tx-abc123")
console.log(`Found ${ctx.learnings.length} relevant learnings`)
for (const l of ctx.learnings) {
console.log(`- [${(l.relevanceScore * 100).toFixed(0)}%] ${l.content}`)
}Returns SerializedContextResult:
| Field | Type | Description |
|---|---|---|
taskId | string | The task ID queried |
taskTitle | string | The task's title |
learnings | SerializedLearningWithScore[] | Relevant learnings with scores |
searchQuery | string | The generated search query |
searchDuration | number | Search time in milliseconds |
tx memory context tx-abc123{
"tool": "tx_context",
"arguments": {
"taskId": "tx-abc123"
}
}curl http://localhost:3456/api/context/tx-abc123Runs
Run-level heartbeat primitives for orchestration loops and watchdogs.
tx.runs.list(options?)
List recent runs with filtering and cursor pagination.
const recent = await tx.runs.list({
limit: 20,
status: ["failed", "running"],
})tx.runs.get(runId)
Get a run with parsed transcript messages and captured stdout/stderr logs.
const detail = await tx.runs.get("run-abc12345")
console.log(detail.run.status)
console.log(detail.messages.length)tx.runs.transcript(runId)
Get parsed transcript entries for a run.
const messages = await tx.runs.transcript("run-abc12345")
const toolCalls = messages.filter((message) => message.type === "tool_use")tx.runs.stderr(runId, options?)
Read stderr for a run, optionally tailing the last N lines.
const stderr = await tx.runs.stderr("run-abc12345", { tail: 50 })
console.log(stderr.content)tx.runs.errors(options?)
Aggregate recent run errors, span errors, and error events.
const errors = await tx.runs.errors({ hours: 24, limit: 10 })tx.runs.heartbeat(runId, data?)
Record transcript/log progress for a running run.
await tx.runs.heartbeat("run-abc12345", {
transcriptBytes: 2048,
deltaBytes: 256,
})tx.runs.stalled(options?)
List currently running runs that appear stalled.
const stalled = await tx.runs.stalled({
transcriptIdleSeconds: 300,
heartbeatLagSeconds: 180,
})tx.runs.reap(options?)
Reap stalled runs by cancelling the run and optionally resetting task status.
const reaped = await tx.runs.reap({
transcriptIdleSeconds: 300,
dryRun: false,
})Spec
Spec traceability primitives bridge docs, tests, and FCI.
tx.spec.discover(options?)
Refresh doc-derived rules from docs and discover mappings from tags, comments, and manifest entries.
const discover = await tx.spec.discover({
doc: "PRD-033-spec-test-traceability",
})tx.spec.link(invariantId, file, name?, framework?)
Create or upsert a manual invariant-to-test mapping.
await tx.spec.link(
"INV-EARS-FL-001",
"test/integration/flow.test.ts",
"flow stays unblocked",
"vitest"
)tx.spec.status(options?)
Get current closure state, including phase, FCI, coverage counts, and blocker reasons.
const status = await tx.spec.status({
doc: "PRD-033-spec-test-traceability",
})tx.spec.matrix(options?)
Get the invariant-to-test traceability matrix with latest run outcomes.
const matrix = await tx.spec.matrix({
doc: "PRD-033-spec-test-traceability",
})tx.spec.run(testId, passed, options?)
Record a single test outcome.
await tx.spec.run(
"test/integration/flow.test.ts::flow stays unblocked",
true,
{ durationMs: 42 }
)tx.spec.batch(data)
Ingest framework-native batch output or pre-parsed result rows.
await tx.spec.batch({
from: "junit",
raw: "<testsuites>...</testsuites>",
})tx.spec.complete(options)
Human sign-off for a HARDEN scope.
await tx.spec.complete({
doc: "PRD-033-spec-test-traceability",
signedOffBy: "james",
})File Learnings
tx.fileLearnings.list(path?)
List all file learnings, optionally filtering by file path.
// List all
const all = await tx.fileLearnings.list()
// Filter by path
const forFile = await tx.fileLearnings.list("src/auth.ts")tx.fileLearnings.recall(path)
Recall file learnings matching a specific file path. Use this before working on a file to retrieve attached notes.
const notes = await tx.fileLearnings.recall("src/auth.ts")
for (const note of notes) {
console.log(`${note.filePattern}: ${note.note}`)
}tx.fileLearnings.add(data)
Associate a note with a file pattern.
await tx.fileLearnings.add({
filePattern: "src/auth.ts",
note: "JWT tokens expire after 1 hour, refresh logic is in middleware",
taskId: "tx-abc123",
})Messages
Inter-agent communication via channel-based messaging.
tx.messages.send(data)
Send a message to a channel.
await tx.messages.send({
channel: "worker-1",
content: "Task tx-abc123 is ready for review",
sender: "orchestrator",
ttlSeconds: 3600,
correlationId: "req-001",
})Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | Yes | Channel name |
content | string | Yes | Message content |
sender | string | No | Sender name (default: 'sdk') |
taskId | string | No | Associated task ID |
ttlSeconds | number | No | Time-to-live in seconds |
correlationId | string | No | For request/reply patterns |
metadata | Record<string, unknown> | No | Arbitrary metadata |
tx.messages.inbox(channel, options?)
Read messages from a channel inbox.
const msgs = await tx.messages.inbox("agent-1", { limit: 10 })
const fromOrch = await tx.messages.inbox("agent-1", { sender: "orchestrator" })Options:
| Option | Type | Default | Description |
|---|---|---|---|
afterId | number | - | Cursor: only messages with ID > this value |
limit | number | - | Maximum messages to return |
sender | string | - | Filter by sender |
correlationId | string | - | Filter by correlation ID |
includeAcked | boolean | false | Include acknowledged messages |
tx.messages.ack(id)
Acknowledge a single message.
await tx.messages.ack(42)tx.messages.ackAll(channel)
Acknowledge all pending messages on a channel.
const { ackedCount } = await tx.messages.ackAll("agent-1")tx.messages.pending(channel)
Get count of pending (unacknowledged) messages.
const count = await tx.messages.pending("agent-1")tx.messages.gc(options?)
Garbage collect expired and old acknowledged messages.
const { expired, acked } = await tx.messages.gc({ ackedOlderThanHours: 24 })Claims
Lease-based task claiming for worker coordination.
tx.claims.claim(taskId, workerId, leaseDurationMinutes?)
Claim a task with an exclusive lease.
const claim = await tx.claims.claim("tx-abc123", "worker-1", 30)
console.log(`Lease expires at ${claim.leaseExpiresAt}`)tx.claims.release(taskId, workerId)
Release a claim on a task.
await tx.claims.release("tx-abc123", "worker-1")tx.claims.renew(taskId, workerId)
Renew an existing claim's lease.
const renewed = await tx.claims.renew("tx-abc123", "worker-1")
console.log(`New expiry: ${renewed.leaseExpiresAt}`)tx.claims.getActive(taskId)
Get the active claim for a task, or null if unclaimed.
const claim = await tx.claims.getActive("tx-abc123")
if (claim) {
console.log(`Claimed by ${claim.workerId}`)
}Guards
tx.guards.set(options)
Set task creation limits (bounded autonomy).
await tx.guards.set({
maxPending: 50,
maxChildren: 10,
maxDepth: 4,
enforce: true,
})Options:
| Option | Type | Description |
|---|---|---|
scope | string | Guard scope: 'global' (default) or 'parent:<task-id>' |
maxPending | number | Maximum non-done tasks |
maxChildren | number | Maximum direct children per parent |
maxDepth | number | Maximum hierarchy nesting depth |
enforce | boolean | true = hard block, false = advisory warnings |
tx.guards.show()
List all configured guards.
const guards = await tx.guards.show()
for (const g of guards) {
console.log(`${g.scope}: maxPending=${g.maxPending}, enforce=${g.enforce}`)
}tx.guards.check(options?)
Check if task creation would pass guard limits without actually creating a task.
const result = await tx.guards.check({ parentId: 'tx-abc123' })
if (!result.passed) {
console.log('Warnings:', result.warnings)
}tx.guards.clear(scope?)
Clear guards. Omit scope to clear all.
await tx.guards.clear() // clear all
await tx.guards.clear('global') // clear global only
await tx.guards.clear('parent:tx-abc123') // clear specific parent scopeVerify
tx.verify.set(taskId, cmd, schema?)
Attach a verification command to a task.
await tx.verify.set('tx-abc123', 'bun run test:auth')
// With structured output schema
await tx.verify.set('tx-abc123', 'bun run test:json', 'verify-schema.json')tx.verify.show(taskId)
Show the verification command attached to a task.
const info = await tx.verify.show('tx-abc123')
// { cmd: 'bun run test:auth', schema: null }tx.verify.run(taskId, options?)
Run the verification command. Returns structured result with exit code, stdout, stderr, and timing.
const result = await tx.verify.run('tx-abc123')
// { taskId: 'tx-abc123', exitCode: 0, passed: true, stdout: '...', stderr: '', durationMs: 1234 }
// With timeout
const result = await tx.verify.run('tx-abc123', { timeout: 60 })tx.verify.clear(taskId)
Remove the verification command from a task.
await tx.verify.clear('tx-abc123')Reflect
tx.reflect.run(options?)
Run a session retrospective. Returns structured metrics, signals, and optionally LLM analysis.
// Data tier (no LLM, instant)
const result = await tx.reflect.run({ sessions: 5 })
console.log(result.throughput) // { created: 20, completed: 8, net: 12, completionRate: 0.4 }
console.log(result.signals) // [{ type: 'HIGH_PROLIFERATION', ... }]
console.log(result.stuckTasks) // [{ id: 'tx-abc123', title: '...', attempts: 4 }]
// Enable analysis when an LLM backend is available
const analyzed = await tx.reflect.run({ sessions: 5, analyze: true })
console.log(analyzed.analysis) // "Proliferation driven by..."analyze: true uses Claude Agent SDK when it is available. If not, tx falls back to ANTHROPIC_API_KEY. Without either backend, you still get the structured data tier.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
sessions | number | 10 | Number of recent sessions to analyze |
hours | number | - | Time window in hours |
analyze | boolean | false | Enable LLM analysis |
Returns SerializedReflectResult:
| Field | Type | Description |
|---|---|---|
sessions | object | Session counts (total, completed, failed, timeout, avgDurationMinutes) |
throughput | object | Task throughput (created, completed, net, completionRate) |
proliferation | object | Proliferation metrics (avgCreatedPerSession, maxCreatedPerSession, maxDepth, orphanChains) |
stuckTasks | array | Tasks with 3+ failed attempts |
signals | array | Machine-readable signals (type, message, severity) |
analysis | string? | LLM analysis (when analyze: true) |
Error Handling
The SDK uses TxError for all errors, with typed error codes:
import { TxError } from "@jamesaphoenix/tx-agent-sdk"
try {
await tx.tasks.get("tx-nonexistent")
} catch (err) {
if (err instanceof TxError) {
if (err.isNotFound()) {
console.log("Task not found")
} else if (err.isValidation()) {
console.log("Validation error:", err.message)
} else if (err.isCircularDependency()) {
console.log("Circular dependency detected")
}
console.log("Error code:", err.code)
console.log("HTTP status:", err.statusCode)
}
}Retry Logic
Built-in retry helper for resilient API calls:
import { withRetry } from "@jamesaphoenix/tx-agent-sdk"
const task = await withRetry(() => tx.tasks.get("tx-abc123"), {
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
backoffMultiplier: 2,
})By default, withRetry retries on network errors and 5xx server responses.
Utility Functions
The SDK exports helper functions for common operations:
import {
filterByStatus,
filterReady,
sortByScore,
getNextTask,
isValidTaskId,
isValidTaskStatus,
parseDate,
wasCompletedRecently,
} from "@jamesaphoenix/tx-agent-sdk"
// Get the highest-priority ready task from a list
const tasks = (await tx.tasks.list({ limit: 100 })).items
const next = getNextTask(tasks)
// Validate IDs
isValidTaskId("tx-abc123") // true
isValidTaskStatus("active") // true
// Filter tasks client-side
const active = filterByStatus(tasks, "active")
const ready = filterReady(tasks)
const sorted = sortByScore(tasks)
// Date helpers
const recentlyDone = tasks.filter((t) => wasCompletedRecently(t, 24))Cleanup
When using direct mode, dispose of resources when done:
const tx = new TxClient({ dbPath: ".tx/tasks.db" })
try {
await tx.tasks.ready()
} finally {
await tx.dispose()
}HTTP mode has no resources to dispose. dispose() is safe to call on either mode.
Example: Full Agent Loop
import { TxClient } from "@jamesaphoenix/tx-agent-sdk"
const tx = new TxClient({ apiUrl: "http://localhost:3456" })
async function agentLoop() {
while (true) {
// Get next task
const ready = await tx.tasks.ready({ limit: 1 })
if (ready.length === 0) break
const task = ready[0]
console.log(`Starting: ${task.title}`)
// Get relevant context
const ctx = await tx.context.forTask(task.id)
const contextStr = ctx.learnings
.map((l) => `- ${l.content}`)
.join("\n")
// ... pass task + context to your LLM ...
// Record what was learned
await tx.learnings.add({
content: "Discovered pattern X works for problem Y",
sourceRef: task.id,
})
// Complete the task
const { nowReady } = await tx.tasks.done(task.id)
console.log(`Done. ${nowReady.length} tasks unblocked.`)
}
console.log("All tasks complete!")
}
agentLoop()Related
- Getting Started - Install tx and run your first agent loop
- tx ready - Task readiness detection
- tx done - Task completion
- tx doc - Docs-first authoring
- tx spec - Spec discovery, scoring, and closure
- tx memory - Memory and contextual retrieval
- tx label - Ready queue scoping by phase
- tx guard - Task creation limits (bounded autonomy)
- tx verify - Machine-checkable done criteria
- tx reflect - Session retrospective and signals
- tx gate - Human-in-the-loop phase approvals
- tx claim - Lease-based task claiming for parallel agents
- tx send / tx inbox - Agent-to-agent messaging
- tx sync - Stream-based event sync
- tx trace - Run and failure observability