# Clearloop

**The agent loop you can read and prove.**

TypeScript AI Agent SDK for Node services: one readable `Runner` loop, Zod tools, handoffs, guardrails, pause/resume approvals, sessions, and Mermaid / OpenTelemetry-shaped traces.

## Install (in your project)

```bash
npm install clearloop-sdk openai zod
```

Set an API key in the environment (do not commit secrets):

```bash
export OPENAI_API_KEY=sk-...
```

Optional packages only if you use those providers:

```bash
npm install @anthropic-ai/sdk          # Anthropic
npm install @google/generative-ai      # Gemini
```

SQLite sessions use Node’s built-in `node:sqlite` (Node 22.5+). No extra package.

## Mental model (3 pieces)

| Piece | Role |
|-------|------|
| **`Agent`** | Config only: instructions, model, tools, handoffs, guardrails |
| **`Runner`** | Runs the loop: `run()` / `resume()` / `runStream()` |
| **`defineTool`** | Zod input schema + `execute` — failures stay inside the loop |

Same `sessionId` → conversation history via a `storage` adapter. Pass app auth as `context` on each run (e.g. `{ userId }`).

## Quick start

```ts
import { Agent, Runner, defineTool, z, openaiProvider } from "clearloop-sdk";

const weather = defineTool({
  name: "get_weather",
  description: "Get current weather for a city",
  inputSchema: z.object({ city: z.string() }),
  execute: async ({ city }) => ({ tempC: 34, condition: "sunny", city }),
});

const agent = new Agent({
  name: "assistant",
  instructions: "You are a helpful assistant. Use tools when needed.",
  model: openaiProvider({ model: "gpt-4o-mini" }),
  tools: [weather],
});

const runner = new Runner({ maxTurns: 6 });
const result = await runner.run(agent, "What's the weather in Gurgaon?");

if (result.status === "completed") {
  console.log(result.output);
  result.trace.print();
}
```

## Use in an API route

Reuse one `Agent` + `Runner` (or create per request). Always branch on `result.status`:

```ts
import { Agent, Runner, InMemoryStorage, openaiProvider } from "clearloop-sdk";
// …define tools + agent once at module scope…

const runner = new Runner({
  maxTurns: 8,
  storage: new InMemoryStorage(), // or FileStorage / SqliteStorage
});

// Example: Express / Fastify / Next route handler body
async function chatHandler(req: {
  body: { message: string; sessionId?: string; userId?: string };
}) {
  const { message, sessionId = "default", userId = "anonymous" } = req.body;

  const result = await runner.run(agent, message, {
    sessionId,
    context: { userId },
  });

  if (result.status === "paused") {
    // Persist result.checkpoint; show Approve/Deny in your UI
    return {
      status: "paused",
      pendingApproval: result.pendingApproval,
      checkpoint: result.checkpoint,
    };
  }

  if (result.status === "failed") {
    return { status: "failed", error: result.error };
  }

  return {
    status: "completed",
    output: result.output,
    agent: result.finalAgentName,
  };
}

// Later, after the human decides:
async function resumeHandler(checkpoint: RunCheckpoint, approved: boolean) {
  const result = await runner.resume(
    checkpoint,
    { approved },
    { agent } // same agent graph you paused with
  );
  return result;
}
```

Import `RunCheckpoint` from `clearloop-sdk` if you type the stored checkpoint.

### Result statuses

| `status` | Meaning | What you do |
|----------|---------|-------------|
| `completed` | Finished | Use `result.output` |
| `paused` | Tool needs approval | Store `checkpoint`, show UI, then `runner.resume(...)` |
| `failed` | Error | Read `result.error` / `result.code` |

## Multi-turn chat

Pass the same `sessionId` and a durable (or in-memory) store:

```ts
import { Runner, InMemoryStorage, FileStorage, SqliteStorage } from "clearloop-sdk";

const runner = new Runner({
  storage: new InMemoryStorage(),
  // storage: new FileStorage(".clearloop/sessions"),
  // storage: new SqliteStorage(".clearloop/sessions.db"),
});

await runner.run(agent, "My name is Maya", { sessionId: "user-42" });
await runner.run(agent, "What is my name?", { sessionId: "user-42" });
// → second turn sees the first messages
```

`RedisStorage` accepts any client with `get` / `set` / `del` (e.g. ioredis).

## Human approval (risky tools)

Mark a tool with `requiresApproval: true`. Without a sync handler, the run **pauses**:

```ts
const refund = defineTool({
  name: "issue_refund",
  description: "Issue a refund",
  inputSchema: z.object({ amount: z.number() }),
  requiresApproval: true,
  execute: async ({ amount }) => ({ refunded: amount }),
});

const paused = await runner.run(agent, "Refund $50");
if (paused.status === "paused" && paused.checkpoint) {
  const done = await runner.resume(
    paused.checkpoint,
    { approved: true }, // or false
    { agent }
  );
}
```

**CLI / scripts:** approve in the same turn with `onApprovalRequired`:

```ts
const runner = new Runner({
  onApprovalRequired: async ({ toolName, input }) => {
    console.log("Approve?", toolName, input);
    return true;
  },
});
```

---

## Tools

```ts
import { defineTool, z } from "clearloop-sdk";

const search = defineTool({
  name: "search",
  description: "Search docs",
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }, ctx) => {
    // ctx.runId, ctx.signal, ctx.dryRun, ctx.context
    return { hits: [] };
  },
});
```

Inputs are Zod-validated. Tool failures become tool messages — they do not crash the loop.

### Parallel tools

Multiple tool calls in one model turn run concurrently by default (`parallelTools: true`). Messages are still appended in call order.

```ts
new Runner({ parallelTools: true });  // default
new Runner({ parallelTools: false }); // sequential
```

## Handoffs

Handoffs are virtual tools named `transfer_to_<agent>`. Message history carries forward. Re-entry loops throw `HandoffLoopError` (`HANDOFF_LOOP`).

```ts
const billing = new Agent({ name: "billing", instructions: "...", model, tools: [...] });
const triage = new Agent({
  name: "triage",
  instructions: "Route billing issues to billing.",
  model,
  handoffs: [billing],
});
```

## Guardrails

Built-ins:

- `noEmptyInput`, `maxInputLength(n)` — input
- `redactPII` — output
- `blockDangerousTools(names)`, `requireApproval(fn)`, `requireOrderOwnership(...)` — tool

```ts
import {
  noEmptyInput,
  redactPII,
  blockDangerousTools,
  requireOrderOwnership,
} from "clearloop-sdk";

guardrails: {
  input: [noEmptyInput],
  output: [redactPII],
  tool: [
    blockDangerousTools(["delete_account"]),
    requireOrderOwnership({
      orders: { "ORD-1001": { userId: "user_alice" } },
      toolNames: ["issue_refund"],
    }),
  ],
}
```

Pass the logged-in user on each run: `runner.run(agent, msg, { context: { userId } })`.

### Typed context

```ts
interface AppContext {
  userId: string;
}

const agent = new Agent<string, AppContext>({
  name: "billing",
  instructions: "...",
  model,
  tools: [issueRefund],
});

await runner.run(agent, "Refund ORD-1001", {
  context: { userId: "user_alice" },
});
```

## Structured output

```ts
const agent = new Agent({
  name: "reporter",
  instructions: "...",
  model,
  outputSchema: z.object({ answer: z.string(), confidence: z.number() }),
});
```

Invalid JSON/schema triggers repairs (`maxOutputRepairAttempts`, default 2), then `OutputValidationError`.

## Streaming and events

```ts
const runner = new Runner({
  onEvent: (e) => console.log(e.type),
});

for await (const event of runner.runStream(agent, "Hello")) {
  if (event.type === "text_delta") process.stdout.write(event.delta);
}
```

Events: `run_started`, `text_delta`, `tool_started`, `tool_completed`, `tool_failed`, `handoff_started`, `guardrail_triggered`, `retry`, `approval_required`, `run_paused`, `run_completed`, `run_failed`.

## Model providers

```ts
import {
  openaiProvider,
  anthropicProvider,
  geminiProvider,
  fallbackProvider,
  withRetry,
} from "clearloop-sdk";

const model = withRetry(
  fallbackProvider([
    openaiProvider({ model: "gpt-4o-mini" }),
    anthropicProvider({ model: "claude-sonnet-4-20250514" }),
    geminiProvider({ model: "gemini-2.0-flash" }),
  ])
);
```

Implement `ModelProvider` (`chat` + optional `chatStream`) for a custom backend. Keys are never written into traces or events.

## Tracing

```ts
result.trace.print({ verbose: true });
console.log(result.trace.toMermaid());   // paste into mermaid.live
console.log(result.trace.toTimeline());
const bundle = result.trace.writeDebugBundle({ events });
// → .clearloop/debug/<runId>/ (report.html, trace.json, …)
console.log(result.trace.toOpenTelemetry());
```

`Runner.replayFromTrace(traceJSON)` re-emits events without calling the model.

## Reliability

**Token budget**

```ts
new Runner({
  tokenBudget: {
    maxPromptTokens: 50_000,
    maxCompletionTokens: 20_000,
    maxTotalTokens: 60_000,
  },
});
```

**Timeouts**

```ts
new Runner({
  timeoutMs: 60_000,
  modelTimeoutMs: 30_000,
});
```

**Retries**

```ts
new Runner({
  maxRetries: 2,
  retryBaseDelayMs: 250,
  onEvent: (e) => {
    if (e.type === "retry") console.log(e.attempt, e.error);
  },
});
```

**Dry-run** (full loop, skip tool `execute` bodies):

```ts
await runner.run(agent, "Do the risky thing", { dryRun: true });
```

## Errors

| Error class | `.code` |
|-------------|---------|
| `MaxTurnsExceededError` | `MAX_TURNS_EXCEEDED` |
| `HandoffLoopError` | `HANDOFF_LOOP` |
| `GuardrailViolationError` | `GUARDRAIL_VIOLATION` |
| `OutputValidationError` | `OUTPUT_VALIDATION_FAILED` |
| `ToolExecutionError` | `TOOL_EXECUTION_FAILED` |
| `BudgetExceededError` | `BUDGET_EXCEEDED` |
| `TimeoutError` | `TIMEOUT` |

All extend `ClearloopError`.

## State layers (reference)

| Layer | What it is | Where it lives |
|-------|------------|----------------|
| **Agent config** | instructions, model, tools, handoffs, guardrails | `Agent` |
| **RunState** | turns, visited agents, tokens, dryRun | One `Runner.run()` |
| **SessionState** | message history | `StorageAdapter` / `SessionManager` |

## Verify this repo (optional)

If you clone the Clearloop repository (not required to use the npm package):

```bash
npm install
npm run test:all          # typecheck + mock (12) + qa (32), no API key
npm run example:basic     # needs OPENAI_API_KEY
npm run example:handoff
npm run example:stream
npm run example:approval  # pause → y/n → resume, no API key
```

For local repo examples, copy `.env.example` → `.env`.

## Compared to other SDKs

| | Clearloop | Typical agent framework |
|--|---------|-------------------------|
| Loop | One readable method | Spread across internals |
| Proof | Mock + QA suites, no API key | Docs-only claims |
| Approvals | Pause/resume + sync shortcut | Often callback-only or missing |
| Observability | Mermaid + OTel JSON | Often proprietary UI |
| Providers | OpenAI / Anthropic / Gemini + interface | Often one-vendor first |
| TypeScript | First-class Zod + generics | Mixed |

## License

MIT — see [LICENSE](./LICENSE).

## Security

See [SECURITY.md](./SECURITY.md) for API-key handling and disclosure guidance.
