Hooks

The other options on createRuntime, for pricing, prompt caching, tracing, sandboxes and built-in tools.

Every optional capability is a plain option on createRuntime: pass it and the capability appears, leave it out and it doesn't exist. The ones with pages of their own: Connections (connections registry, vault), Memory, Limits. The rest are catalogued here.

Pricing — per-run cost

ts
import { staticPricing, computeCost } from "@intentface/latch-core";

createRuntime({
  // …
  pricing: staticPricing({
    "claude-sonnet-4-6": {
      inputPer1M: 3,
      outputPer1M: 15,
      cacheReadPer1M: 0.3,
      cacheWritePer1M: 3.75,
    },
  }),
});

Without it, recorded cost is 0 — usage is still tracked. ModelPrice handles what real invoices actually contain: separate cache-read and cache-write rates, and a tier that reprices the whole turn once input exceeds a threshold (long-context pricing).

Cost lands in three places: the message metadata, the run row, and a usage row per turn — so per-tenant COGS is a query, not a reconstruction.

Prompt caching — the 6× default

On by default for first-party Anthropic and OpenAI models — no configuration needed. An MCP-heavy agent re-sends its tool catalogue on every step; provider prompt caching is the difference between a viable and an unviable unit cost:

Same question, same agentInputCache readCost
Without caching33,3550$0.1698
With, warm29,02827,306$0.0290

The default derives the provider family from the model object itself and places the breakpoints for you (tools → system prompt → a rolling request-level marker, with the volatile date line kept after the system breakpoint so the daily flip never invalidates it). Supply resolvePromptCaching when you own a model catalog or need per-principal cache routing:

ts
import { promptCachingPlanFor } from "@intentface/latch-core";

createRuntime({
  // …
  resolvePromptCaching: (modelId, { agent, memoryScoped, principal }) =>
    promptCachingPlanFor(providerOf(modelId), {
      agent,
      // Memory-enabled agents have per-principal system prompts — scope
      // OpenAI's cache routing key so different prefixes don't co-route.
      cacheKeySuffix: memoryScoped ? ownerKey(principal) : undefined,
    }),
});

Off switches: resolvePromptCaching: () => undefined (everywhere), or promptCaching: false on a single runAgent call (the control arm of an A/B cost comparison). The built-in plan leaves the cache TTL at Anthropic's 5-minute default, so agents whose turns arrive slower than that won't benefit from it as shipped — Anthropic also offers an explicit ttl: "1h" (at a higher cache-write price), which you can opt into by returning your own plan: { ...cacheControl: { type: "ephemeral", ttl: "1h" } } from resolvePromptCaching.

Telemetry — tracing

ts
import { otelTelemetry } from "@intentface/latch-otel";

createRuntime({
  // …
  telemetry: otelTelemetry({ user: (p) => p.orgId }), // OTLP to OTEL_EXPORTER_OTLP_ENDPOINT
});

The option supplies per-run AI SDK telemetry options, an optional trace wrapper around the model call, and a post-turn hook. latch-otel implements it over OpenTelemetry: one trace per run, its id derived from the run id, with the AI SDK's model and tool spans under it, sent over OTLP to any backend (export: langfuse() for Langfuse). Leave it out and nothing is traced, at zero cost.

Telemetry hooks — usage, analytics, and "someone needs to be told"

You don't need a tracer to use it. LatchTelemetry is also the place for plain callbacks: onStep after each generation step, onRunFinished after the turn is persisted. Every hook is guarded — a thrown or rejected hook is swallowed, so telemetry can never break a turn.

ts
createRuntime({
  // …
  telemetry: {
    onStep: (run, step) => metrics.increment("agent.step", { agent: run.agent }),
    onRunFinished: (run, outcome) => analytics.track("turn_finished", { ...run, ...outcome }),
  },
});

run carries principal, chatId, runId, agent, modelId, startedAt, depth, trigger, and the subagent linkage (parentRunId, rootChatId); outcome adds status, usage, cost, the error message when it errored, and the turn's messages. Two fields make the hook actionable rather than merely statistical:

  • depth — 0 = top-level turn, >0 = a subagent turn.
  • trigger — what started the turn, and so whether anyone is watching it: "chat" (a user message), "decision" (a human answered an approval or a client-handled tool), "schedule" (a cron/one-shot fire), "programmatic" (runAgent), "resume" (the durability sweep recovering a crashed run). A subagent inherits its parent's trigger, so a whole delegation tree stays attributable to what set it off.

That combination is how you push a notification exactly when one is warranted — a turn that finishes awaiting_input is a person who must be told when the trigger is "schedule" or "resume", and just a gate rendering in front of someone who can answer it when it's "chat" or "decision":

ts
telemetry: {
  onRunFinished: async (run, outcome) => {
    if (outcome.status !== "awaiting_input") return;
    if (run.trigger !== "schedule" && run.trigger !== "resume") return;
    // Nobody is at the other end of this turn — go tell its owner.
    await notify(run.principal, { chatId: run.chatId, cost: outcome.cost });
  },
},

A tracer and these callbacks are one object: otelTelemetry({...}) implements telemetryFor/withRunSpan/onRunFinished; add your own onStep alongside by wrapping or composing. An impl whose telemetryFor returns undefined for a run opts that run out of every hook.

Sandbox — a workspace for the agent

ts
createRuntime({
  // …
  sandbox: ({ principal, chatId, agent, provider }) =>
    provider === "daytona"
      ? createDaytonaSession({ chatId })
      : createLocalSession({ chatId }),
  harnessTools: { sandbox: sandboxTools, app: appTools },
});

Core holds no sandbox backend. It resolves the AI SDK Experimental_SandboxSession through this function and passes it to the model; the tools that operate on it come from harnessTools.sandbox. One sandbox per chat.

Harness tools

ts
harnessTools: {
  sandbox,          // bash / read_file / write_file / glob / grep
  app,              // web_fetch, todo, ask_question — record or per-turn factory
  render,           // headless-browser fetch (separate capability flag)
  approval,         // default gates by tool name, e.g. { create_schedule: "user-approval" }
}

approval here is a default — an agent that names the same tool in its own toolApproval wins, so a host can ship a side-effecting harness tool gated by default without freezing that choice for every agent.

Provider tools and reasoning

ts
resolveProviderTools: (modelId) =>
  modelId.startsWith("claude")
    ? { web_search: anthropic.tools.webSearch_20260209() }
    : {};

resolveReasoningOptions: (modelId, effort) =>
  modelId.startsWith("claude")
    ? { providerOptions: { anthropic: { thinking: { budgetTokens: budgetFor(effort) } } } }
    : undefined;

Both exist so core carries no provider SDK. You don't have to write them: @intentface/latch-core/models ships providerDefaults(catalog), which supplies these two plus pricing and resolvePromptCaching from one models.dev-backed catalog. Keep the tool keys consistent across providers (web_search, web_fetch) and your prompts and rendering stay provider-agnostic.

Extensions — moments and effects

Every option above adds one capability. extensions is how you add a capability of your own: an extension attaches handlers to the moments of a turn and returns effects, and the runtime composes any number of them with one merge rule per moment. Attach them runtime-wide or per agent; with none attached, a turn is exactly the same as without them.

ts
import { defineExtension } from "@intentface/latch-core";

const auditTools = defineExtension("audit", (ext) => {
  // Refuse a tool for this tenant. First block wins.
  ext.on("tool_call", ({ toolName }, run) =>
    toolName === "delete_record" && plan(run.principal) === "free"
      ? { block: true, reason: "Deleting records needs the Team plan." }
      : undefined,
  );
  // Tell the customer something after each step. The model never sees it.
  ext.notify((step) => `${step.usage.inputTokens ?? 0} tokens in`);
  // A slash command answered without a model call.
  ext.registerCommand("stats", { description: "Turns this month", handler: async () => `${await count()} turns` });
});

createRuntime({
  // …
  extensions: [auditTools],            // every turn
});
defineAgent({ /* … */, extensions: [x] }); // this agent's turns, after the runtime's

A handler returns an effect, never a rewritten world; undefined means no effect. The effect type per moment is the permission — you cannot do everything everywhere:

MomentEffectTouches
session_start, before_agent_start{ inject } / { replace }the stored transcript, before the model runs
input{ transform } / { handled, reply }the user's text, or answer without the model
register_toolsa new toolsettools — additions are capabilities, always active
tool_call{ block, reason }a veto; the reason reaches the model as the result
tool_result{ output }the stored result and what the model reads
instructions{ append } / { replace }the system prompt this turn (a lens)
context{ messages }the model's view of history this turn (a lens)
step_start{ stop, model, activeTools }whether and how the next step runs
step_end{ append }parts appended to the reply — stored, and streamed live
render{ hide } / { replace }what the customer sees — every part on load, and extension-added parts live; never storage
error, agent_end—observe

Three rules that follow from the table. Storage keeps the truth: tool_result changes what is stored, so a secret masked there is gone everywhere; render changes only the view, for the rarer case where the model should keep the truth and the person should not see it. Order matters at step_end: handlers run in extension order and the next model call waits for all of them, so list an extension that persists before the ones that observe. Failure is open by default: a throwing handler is reported and skipped, so one broken extension never breaks a turn — except one marked critical, which fails the run instead, because a persistence handler whose database is down has lost the step, not produced nothing.

Identity reaches every moment as a trailing run argument (run.principal). Declare its shape once and every handler is typed:

ts
declare module "@intentface/latch-core" {
  interface Principal { orgId: string; userId: string }
}

Not available to extensions yet: per-step re-evaluation of instructions/context (both apply to the turn's prompt), live render of the model's own text, and an extension contributing routes, tables or jobs. Runtime-wide extensions also render on loadHistory; an agent's own do not, since no agent is resolved there.