# Platform & operations recipes (/docs/platform-recipes)



Four complete-enough-to-crib implementations covering the patterns
a platform or ops team reaches for: long-running async jobs,
multi-step approval [interrupts](/docs/interrupts), per-call cost
reporting against the [audit ledger](/docs/audit-ledger), and
OpenTelemetry wiring with in-process span access. Each links back
to the reference page for the primitives it builds on.

For consumer-facing wiring (streaming chat, custom tools, custom
storage, BYOK, moderation, multi-tenant, compliance), see
[Recipes](/docs/recipes).

## 1. Long-running async job tool [#1-long-running-async-job-tool]

A tool that dispatches work to a queue, emits progress, and
resolves when the job completes.

```typescript
// lib/tools/runBenchmark.ts
import { defineTool } from "@pleach/core";

export const runBenchmark = defineTool({
  name: "run_benchmark",
  description: "Run a multi-hour benchmark; returns results when complete.",
  inputSchema: z.object({
    suite:  z.string(),
    config: z.record(z.unknown()),
  }),
  async execute(input, ctx) {
    const job = await queue.enqueue({
      type:   "benchmark",
      suite:  input.suite,
      config: input.config,
    });

    // Wait for terminal status. Honor abort.
    return new Promise((resolve, reject) => {
      const onComplete = (result) => resolve(result);
      const onFail     = (err) => reject(new Error(err.message));
      const onAbort    = () => {
        queue.cancel(job.id);
        reject(new Error("Aborted"));
      };

      ctx.signal?.addEventListener("abort", onAbort);
      queue.on(job.id, "complete", onComplete);
      queue.on(job.id, "fail", onFail);
    });
  },
});
```

Two patterns at play:

1. The tool returns a `Promise` that settles only on the queue's
   terminal `complete`/`fail` event — the runtime awaits the tool
   for the full job duration and surfaces the result when it lands.
2. The abort signal (`ctx.signal`) cancels both the runtime-side
   wait and the underlying queue job, so the user pressing stop
   reclaims the queue slot.

See [Stream events](/docs/stream-events) for the `job.*` event
shapes.

## 2. Multi-step interrupt flow (approve → edit → execute) [#2-multi-step-interrupt-flow-approve--edit--execute]

A tool that requests human approval for arguments before
executing, with the option to edit.

```typescript
const runtime = new SessionRuntime({
  interrupt: {
    enabled: true,
    perToolApproval: {
      requireApprovalFor: ["delete_resource", "send_email"],
      defaultDecision:    "pending",
    },
  },
  // ...
});
```

Drive the turn and resolve each approval request as it arrives.
The stream pauses on `interrupt.requested`; call
`runtime.interrupts.resolve` with an `ApprovalDecision` to resume:

```tsx
import type { ApprovalDecision } from "@pleach/core";

for await (const event of runtime.executeMessage(sessionId, prompt)) {
  if (event.type === "interrupt.requested") {
    const { toolCall } = event.interrupt;

    // Show your modal for `toolCall.name` / `toolCall.arguments`,
    // then file the user's decision:
    const decision: ApprovalDecision = await showApprovalUI(toolCall);
    //   approve as-is:            { approved: true }
    //   approve with edited args: { approved: true, modifiedArguments: {...} }
    //   reject (tool won't fire): { approved: false }

    runtime.interrupts.resolve(event.interrupt.id, decision);
  }
}
```

`resolve` resumes the turn from where it paused. A tool approved
as-is fires with its original args; `modifiedArguments` dispatch it
with edited args; `{ approved: false }` skips it. For a React
component surface that tracks the pending interrupts as state, use
`useInterruptUI` from `@pleach/core/react` (see
[Interrupts](/docs/interrupts)).

See [Interrupts](/docs/interrupts).

## 3. Per-call cost reporter [#3-per-call-cost-reporter]

A `ProviderDecisionLedger` decorator that emits a per-call cost
event for a billing pipeline.

```typescript
import type { ProviderDecisionLedger, AuditableCall } from "@pleach/core/audit";
import { lookupModelCost } from "@pleach/core/query";

class CostEmittingLedger implements ProviderDecisionLedger {
  constructor(
    private primary: ProviderDecisionLedger,
    private emit:    (event: CostEvent) => void,
  ) {}

  async recordCall(call: AuditableCall): Promise<void> {
    const cost = lookupModelCost(call.call.model);
    if (call.tokenCost) {
      const usage = call.tokenCost;
      // `cost.input` / `cost.output` are USD per 1K tokens, so
      // cents = tokens * rate / 1000 * 100 = tokens * rate / 10.
      const cents = Math.ceil(
        (usage.inputTokens  * cost.input +
         usage.outputTokens * cost.output) / 10,
      );
      this.emit({
        sessionId: call.sessionId,
        turnId:    call.turnId,
        modelId:   call.call.model,
        cents,
        at:        call.createdAt,
      });
    }
    return this.primary.recordCall(call);
  }
}
```

Wire it in:

```typescript
appRegistries.setProviderDecisionLedgerFactory(
  () => new CostEmittingLedger(
    new SupabaseProviderDecisionLedger(supabase),
    (event) => billing.recordCost(event),
  ),
);
```

Every LLM call produces a cost event before the row lands in the
ledger. Idempotent on `(sessionId, turnId, stageId, seqWithinTurn)`
— retries don't double-charge.

See [Audit ledger](/docs/audit-ledger).

## 4. OpenTelemetry wiring + reading spans in-process [#4-opentelemetry-wiring--reading-spans-in-process]

A production OTel setup that exports the four substrate-emitted
spans — `session.turn`, `llm.invocation`, `graph.stage`,
`tool.execution` — plus the in-process `runtime.spans` path so
tests and DevTools can read the trace tree without an external
collector.

### `lib/otel.ts` [#libotelts]

```typescript
// lib/otel.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";

export function startOtel() {
  const sdk = new NodeSDK({
    resource: new Resource({
      [SemanticResourceAttributes.SERVICE_NAME]:
        process.env.OTEL_SERVICE_NAME ?? "agent-api",
      [SemanticResourceAttributes.SERVICE_VERSION]:
        process.env.GIT_SHA ?? "dev",
    }),
    traceExporter: new OTLPTraceExporter({
      url:     process.env.OTLP_ENDPOINT,
      headers: { "x-honeycomb-team": process.env.HONEYCOMB_API_KEY ?? "" },
    }),
  });

  sdk.start();
  return sdk;
}
```

Start the SDK before constructing any `SessionRuntime`. The
`pleach.tenant_id` attribute is set on every span automatically
when `runtime.tenant` is configured — no per-call wiring.

### `lib/runtime.ts` [#libruntimets]

```typescript
// lib/runtime.ts
import { SessionRuntime } from "@pleach/core";

export function buildRuntime(userId: string, tenantId: string) {
  return new SessionRuntime({
    storage:     pgAdapter,
    userId,
    tenantId,
    otelFlushIntervalTurns: 1,   // flush at the end of every turn
  });
}
```

`otelFlushIntervalTurns: 1` drains the exporter buffer per turn —
lowest tail latency for dev. Raise it (`5`, `10`) under
high-throughput production where per-turn flush cost shows up.

### `app/api/dev/spans/route.ts` [#appapidevspansroutets]

```typescript
// app/api/dev/spans/route.ts
import { buildRuntime } from "@/lib/runtime";

export async function GET(req: Request) {
  const runtime = buildRuntime("dev-user", "dev-tenant");
  const spans   = runtime.spans.snapshot();
  return Response.json({ spans });
}
```

`runtime.spans.snapshot()` returns the in-process buffer (last
1,000 spans by default) without round-tripping through an
exporter. Useful for DevTools panels and integration tests that
assert on the trace tree directly.

See [OTel observability](/docs/otel-observability) and [Facets](/docs/facets).

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Recipes" href="/docs/recipes" description="The consumer-facing recipes — streaming chat, custom tools, custom storage, BYOK, moderation, multi-tenant, compliance." />

  <Card title="Observability" href="/docs/observability" description="The full OTel + Datadog + Honeycomb wiring around the ledger and span exports." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The ProviderDecisionLedger the cost reporter decorates." />

  <Card title="Interrupts" href="/docs/interrupts" description="The interrupt contract the approval recipe builds on." />

  <Card title="Deployment" href="/docs/deployment" description="Production deployment patterns — long-lived processes, serverless functions, edge runtime constraints." />
</Cards>
