Cosmo Realtime SDK
Guides

Add production guardrails

Scrub sensitive arguments, gate tool calls with a classifier, audit every outcome, and verify the call is complete before it ends.

An agent's production failures are rarely about what it says — they are about what it does: an argument that carries a card number into an external service, a prompt-injected tool call, a side effect nobody logged, a call that hangs up before the job is done. Instructions ask the model to avoid all of this; hooks and tool grants guarantee it, in code that runs on every call whatever the model decides.

This guide composes those seams into four guard patterns. It assumes the hook mechanics — seams, matchers, fold order — from Hooks.

Scrub arguments before a tool runs

A PreToolUse hook sees every matching client-tool call before the handler does and can rewrite the arguments the handler receives. That makes it the seam for redaction: strip sensitive data before it reaches a tool that forwards text to an external service — a ticketing system, a CRM, a search API.

import { preToolUse } from 'cosmo-ai';

const scrubPii = preToolUse(
  (ctx) => ({
    updatedArguments: { ...ctx.arguments, notes: redactPii(String(ctx.arguments.notes ?? '')) },
  }),
  { matcher: 'create_ticket' },
);

const agent = client.agent({ instructions: '…', tools: [createTicket], hooks: [scrubPii] });
from cosmo_ai.hooks import PreToolUseResult, pre_tool_use

@pre_tool_use(matcher="create_ticket")
def scrub_pii(ctx) -> PreToolUseResult:
    return PreToolUseResult(
        updated_arguments={**ctx.arguments, "notes": redact_pii(ctx.arguments["notes"])}
    )

agent = client.agent(instructions="…", tools=[create_ticket], hooks=[scrub_pii])
let scrubPII = try preToolUse(matcher: "create_ticket") { ctx in
    var arguments = ctx.arguments
    if case .string(let notes)? = arguments["notes"] {
        arguments["notes"] = .string(redactPII(notes))
    }
    return PreToolUseResult(updatedArguments: arguments)
}

The handler, every later hook, and the audit trail all see the scrubbed arguments — rewrites chain in list order. A glob matcher (crm_*) applies one scrubber to a whole family of tools.

Gate a call on a classifier

A deny decision doesn't have to be a static rule. The hook body is ordinary code, so the verdict can come from a model: a prompt-injection classifier over arguments derived from untrusted content, a moderation check over user-supplied text. PreToolUse is the seam where a classifier can actually stop the action instead of commenting on it afterward.

One rule matters here: a hook that throws fails open — the call proceeds. A guard whose verdict comes from a network call must catch its own failure and deny explicitly:

const injectionGate = preToolUse(async (ctx) => {
  try {
    if (await looksInjected(ctx.arguments)) {
      return { permission: 'deny', reason: 'that request looked like an instruction smuggled into outside content — check with the user before acting on it' };
    }
  } catch {
    return { permission: 'deny', reason: 'the safety check is unavailable — try again in a moment' };
  }
}, { matcher: 'send_email' });
from cosmo_ai.hooks import PreToolUseResult, pre_tool_use

@pre_tool_use(matcher="send_email")
async def injection_gate(ctx) -> PreToolUseResult:
    try:
        if await looks_injected(ctx.arguments):
            return PreToolUseResult(
                permission="deny",
                reason="that request looked like an instruction smuggled into outside "
                       "content — check with the user before acting on it",
            )
    except Exception:
        return PreToolUseResult(
            permission="deny", reason="the safety check is unavailable — try again in a moment"
        )
    return PreToolUseResult()
let injectionGate = try preToolUse(matcher: "send_email") { ctx in
    do {
        if try await looksInjected(ctx.arguments) {
            return PreToolUseResult(permission: .deny, reason: "that request looked like an instruction smuggled into outside content — check with the user before acting on it")
        }
        return nil
    } catch {
        return PreToolUseResult(permission: .deny, reason: "the safety check is unavailable — try again in a moment")
    }
}

The deny reason is model-facing prose the agent can act on, the same register as a thrown tool error. The same shape places a classifier at any seam a hook fires: SessionStart to inject per-user policy before the first turn, PostToolUse to flag a result after the fact.

Audit every tool call

A PostToolUse hook with no matcher observes every client-tool outcome as a typed ToolOutcome — ok, error, or denied — so a call denied by a guard lands in the same trail as a handler failure. Skill loads are ordinary tool calls and show up too.

import { postToolUse } from 'cosmo-ai';

const audit = postToolUse((ctx) => {
  auditLog.write({
    tool: ctx.toolName,
    session: ctx.sessionId,
    outcome: ctx.outcome.kind, // 'ok' | 'error' | 'denied'
  });
});
from typing_extensions import assert_never  # `typing` on 3.11+

from cosmo_ai.hooks import ToolDenied, ToolError, ToolOk, ToolOutcome, post_tool_use

def outcome_kind(outcome: ToolOutcome) -> str:
    match outcome:
        case ToolOk():
            return "ok"
        case ToolError():
            return "error"
        case ToolDenied():
            return "denied"
        case _:
            assert_never(outcome)

@post_tool_use
def audit(ctx) -> None:
    audit_log.write(
        tool=ctx.tool_name,
        session=ctx.session_id,
        outcome=outcome_kind(ctx.outcome),
    )
let audit = try postToolUse { ctx in
    recordAudit(tool: ctx.toolName, session: ctx.sessionId, outcome: ctx.outcome)
}

PostToolUse covers tools that run in your process. Server tools report through the tool lifecycle events instead, so a complete audit trail combines the hook with an event-stream subscriber sharing the same sink.

Verify the call is complete before it ends

Hanging up is a decision worth gating: end too early and the job the call existed for is not done. end_call is a server tool, so PreToolUse doesn't intercept it — the guarantee is in which tools you grant. What you can enforce in code is the wrap-up in front of it.

Declare a client wrap-up tool and instruct the agent to call it before saying goodbye. Its handler checks the call's goals — deterministically (every required field collected), or with a quick model read over the transcript your app accumulated from final transcript events. While the goals are unmet, throw: the error message is the model-facing course correction.

import { clientTool } from 'cosmo-ai/tool';
import { zodInput } from 'cosmo-ai/tool/zod';
import { z } from 'zod/v4';

const wrapUpCall = clientTool({
  name: 'wrap_up_call',
  description: "Confirm the call's goals are met. Call this before ending the call.",
  input: zodInput(z.object({ outcome: z.string().describe('What was accomplished') })),
  handler: async ({ outcome }) => {
    const missing = await unmetGoals(transcriptSoFar(), outcome);
    if (missing) {
      throw new Error(`Not done yet: ${missing}. Resolve it, or offer to connect the caller with a person.`);
    }
    return { status: 'complete' };
  },
});
from pydantic import BaseModel
from cosmo_ai import tool

class WrapUpInput(BaseModel):
    outcome: str

@tool
async def wrap_up_call(input: WrapUpInput) -> dict[str, str]:
    """Confirm the call's goals are met. Call this before ending the call."""
    missing = await unmet_goals(transcript_so_far(), input.outcome)
    if missing:
        raise RuntimeError(
            f"Not done yet: {missing}. Resolve it, or offer to connect the caller with a person."
        )
    return {"status": "complete"}
struct WrapUpArgs: Decodable, Sendable { let outcome: String }
struct NotDone: LocalizedError { let errorDescription: String? }

let wrapUpCall = try AgentTool.clientTool(
    name: "wrap_up_call",
    description: "Confirm the call's goals are met. Call this before ending the call.",
    input: .object(properties: ["outcome": .string(description: "What was accomplished")], required: ["outcome"])
) { (args: WrapUpArgs) in
    if let missing = try await unmetGoals(transcriptSoFar(), args.outcome) {
        throw NotDone(errorDescription: "Not done yet: \(missing). Resolve it, or offer to connect the caller with a person.")
    }
    return ["status": .string("complete")]
}

Then pick who hangs up. Granting end_call alongside the wrap-up tool lets the agent close the call itself once the wrap-up succeeds — the instructions carry the ordering. For a hard guarantee, withhold end_call and have your code call session.end() when the wrap-up returns complete.

Calls also end without a goodbye — the caller hangs up, the network drops, a silence timeout fires. A SessionEnd hook runs exactly once on every exit path, which makes it the safety net: check the same goals there and queue human follow-up for whatever ended incomplete.

from cosmo_ai.hooks import session_end

@session_end
def escalate_incomplete(ctx) -> None:
    if not call_goals_met():
        follow_up_queue.add(session_id=ctx.session_id, reason=ctx.reason.value)

The factory is sessionEnd in TypeScript and sessionEnd(_:) in Swift, with the same context: the disconnect reason, optional detail, and the session id.

Next steps

On this page