kelam.sh Docs Try it Add to Claude
Reference

The agent contract

Everything an agent is made of. You don't have to memorise any of it — the Builder writes these files and kelam verify catches mistakes — but this is the page to have open when you're editing by hand.

The live source of truth is kelam schema, which prints the contract as data: the full JSON Schema plus every allowed model, voice and background noise. Narrow it with a field path — kelam schema turn.detection, kelam schema tts, kelam schema tests. This page is the readable companion; where they disagree, the CLI is right.

The folder

# ~/.kelam/agents/<name>/
agent.yaml           # the config contract — this page
scenarios/*.md       # concatenated into the system prompt, in filename order
tools/*.py           # @tool functions the agent can call mid-conversation
tests/*.yaml         # assertions, run by `kelam test`
.agentmeta.json      # read-only server facts (id, version, numbers) — push ignores it

KELAM_HOME overrides ~/.kelam. Because agents live in one per-user home, every command finds them from any directory — and inside a pulled folder most commands need no agent argument at all.

agent.yaml — top level

FieldDefault
nameUnique in the workspace. Also how other agents refer to it.
description""What this agent handles. Read by a routing front desk to decide whether to send a caller here — write it like a job description.
languageenThe call's primary language. One knob: it derives the STT language, is enforced on TTS, and steers the prompt.
languages[]Every language it can speak (must include language). More than one makes it multilingual — code-switching speech recognition, plus a tool to switch mid-call. New agents scaffold as [en, es, fr].
noise_cancellationncnc removes environmental noise from the caller's audio; bvc_telephony also suppresses competing background voices (costs extra); off passes raw audio.
background_audioofficeThe ambient room behind the agent's voice — a dead-silent line is what reads as "robot". Choices: off, office, keyboard-typing, city, crowded-room, forest, hold-music, rain, airplane-cabin.
greeting{}The opening line, per language, spoken verbatim. Empty means it waits for the caller to speak first. When set, needs one entry per spoken language.
enginecascadeHow the call runs — see below.
llm / stt / tts / turnThe cascade pipeline's knobs — see below.
phoneDirection and texting — see below.
toolsend_call, dtmf, holdThe built-in capabilities this agent gets. Presence in the list is enabled.
outboundHow it opens calls it places — see below.
context{}Static facts it carries into every call: a base URL, a booking link, a rate. Versioned with the agent; non-secret.
compliancehipaa: true marks the agent as handling PHI. Until baa_attested: true, verify and deploy refuse to ship it, and recording defaults off. A guard, not compliance in itself.

phone

inboundWhether it should answer calls to the number. The workspace's first inbound-capable agent claims the number.
outboundWhether it may place calls.
sms_autoresponderWhen true, it replies to inbound texts on its number in its own persona, using its own tools.
sms_instructionsExtra steering for text mode only — "keep replies short, never quote prices".

Voice, models and language

engine

engine.type: cascade (the default) runs speech-to-text → model → text-to-speech, which is what the stt, llm, tts and turn sections configure. engine.type: realtime replaces the whole loop with a single speech-to-speech model: lower latency and more natural prosody, at the cost of a fixed provider voice set (tts.* stops applying), weaker tool reliability, and audio-token pricing. Under realtime the model handles turn-taking and language switching itself. Custom tools, built-in tools, context, transcripts and recordings work the same either way.

llm

modelWhich model runs the conversation. kelam schema llm.model lists what's allowed.
temperature0–1, default 0.4. Lower is more consistent, which is usually what a phone agent wants.
max_tokensDefault 160 — phone replies are one or two sentences, and a low ceiling both keeps it terse and shortens the slowest turns. Raise it if the agent reads back long content or has tools with large text arguments.

tts

voice_idThe voice. Audition them in the browser (kelam show), or clone your own with kelam show --clone-voice.
voice_by_languageA native voice per language, so a Spanish call doesn't get an accented English voice. A language with no entry falls back to voice_id.
voice_settingsOptional tuning. Lower stability is more expressive; higher style is more emotive; speed runs 0.7–1.2. Omit the block for the model's defaults.

Turn-taking — the knobs that make it feel human

More perceived quality lives here than anywhere else in the file. If an agent feels like it "talks over me" or "takes forever", this is the section.

detectionvad (default) waits for a silence timer — fastest and most predictable. model decides from content whether the caller has finished — more robust to mid-sentence pauses, but adds a second or two before replying. stt uses the transcription provider's own endpointing.
endpointing_min_msSilence to wait before replying, default 500. Raise to 700–900 for a calmer, more deliberate agent; lower for a snappier one.
preemptive_generationStart composing the reply while the caller is still finishing. On by default; it's most of the felt speed.
interruptibleWhether the caller can cut it off mid-sentence. Leave on — people interrupt.
min_interruption_wordsTranscribed words needed before it counts as an interruption. Default 1, so a cough or a TV doesn't cut the agent off but a real "wait" does. 0 is hair-trigger.
min_interruption_msMinimum speech duration to count as an interruption.
interruption_modeadaptive (default) tells a real interruption apart from backchannel ("uh-huh", "okay"), so the agent finishes its sentence instead of stopping on every sound. vad is the blunter behavior.

Built-in tools

One list, entries discriminated by type. Listing an entry is what enables it — there is no separate flag. A fresh agent ships with end_call, dtmf and hold; the rest are opt-in.

# agent.yaml
tools:
  - type: end_call
  - type: dtmf
    instructions: on a pharmacy menu, always choose the refill option
  - type: hold
    max_wait_seconds: 120
    announce_on_return: true
  - type: transfer
    number: "+16175550142"
    destinations:
      - reason: billing
        number: "+16175550100"
        description: invoices, refunds, payment plans
      - reason: sales
        number: "+16175550111"
        description: new business and quotes
  - type: handoff
    targets:
      - name: scheduling
        description: booking, moving and cancelling appointments
    any_agent: false
    max_depth: 3
  - type: owner_callback
    instructions: only for a clear A-grade lead who asks to speak to someone
  - type: fetch_context
  - type: background_noise
  - type: ask_operator
typeWhat the model gets
end_callHang up deliberately after a natural wrap-up. Without it, a call ends only when the other side hangs up or the room times out.
dtmfPress keypad keys — how it navigates "press 2 for billing" on outbound calls — and keys the other side presses are relayed into the conversation. Phone calls only. On by default.
holdRecognise it's been put on hold, go quiet, and re-engage when a person comes back. Mostly for calls the agent places. On by default.
transferHand the caller off to a real phone number (a SIP REFER — the caller leaves the platform). number is the default target; destinations adds reason-routed ones and the agent picks by why they need a human. Phone calls only.
handoffHand the live call to another of your agents in the same room — recording, transcript and event stream continue under one call id. targets is a curated list; any_agent: true makes it a switchboard that discovers colleagues live. max_depth guards against loops.
owner_callbackDial the account owner into the call as an added participant — everyone stays on the line. Rides the same escalation tool as transfer.
fetch_contextRe-read the stored context lanes mid-call, so it sees anything written since the call started. The opening seed happens regardless.
background_noiseChange the ambient room mid-call — moving from an office to a quiet room as the conversation changes.
ask_operatorHand a piece of freeform back-office work to the workspace's operator during the call. Only active on a deployment with an operator configured.

Every agent also gets, without configuration, the ability to read its own past: its recent message threads and its previous call transcripts with whoever is on the line. Any agent with a handoff entry additionally gets a live colleague directory.

Outbound behavior

outbound:
  listen_first: true
  voicemail: leave              # or `hangup`
  voicemail_message: ""         # empty = compose one per call

listen_first (on by default) makes the agent a listener before a speaker: instead of blurting a greeting the moment the line answers, it waits, classifies what picked up — a person, voicemail, a phone menu, or another company's AI receptionist — and only then engages. The verdict is stored on the call as answered_by, which is how you split a batch into "reached a human" and "didn't".

voicemail decides what happens at a mailbox that can take a message: leave records one brief message and drops the line; hangup leaves nothing. A full or unset mailbox always hangs up either way. Per call: --no-voicemail, --voicemail-message "...".

Turning listen_first off restores speak-first calls, which suits an agent calling people who are expecting it.

Scenarios

Every .md file in scenarios/ is concatenated into the system prompt in filename order — hence the 01-, 02- convention. Split by topic once one file gets long: instructions, then the FAQ, then the escalation rules.

Two things that fail on a live call:

Bracketed placeholders — [customer name], [price] — get spoken aloud verbatim. Inline the real value, or describe where the agent should look for it.

context("KEY") is tool-code only. In scenario text it's just characters, and the agent will read it out. To use a stored fact in a scenario, rely on the seed (stored context is already in the agent's head) or describe its location: "the renter's name is under agent in your context — say the actual value when asked".

Your own tools

Any .py file in tools/ is imported and every function decorated with @tool becomes callable by the agent mid-conversation. The signature and the docstring are the contract the model reads, so write the docstring for the model.

from kelam import tool, secret, context

@tool
async def order_status(order_id: str) -> str:
    """Current status of a customer order.

    order_id: the order number the caller reads out, digits only.
    """
    ...
Async or syncBoth work. Async is better — a sync function that blocks holds up the call.
Timeout8 seconds, hard. A tool call happens inside a conversational turn, so slow is audible.
Return valueA string the model reads. Short and factual beats a JSON dump.
ArgumentsAnnotated parameters become the tool's schema. Keep them few and simple — the model fills them from a spoken conversation.
FilenamesPlain name.py — letters, digits, underscore, hyphen.

Metadata is derived by actually importing the file through one shared loader — the same one the worker uses — so kelam verify catching a problem means the worker would have caught it too.

Tool code runs in-process and is not sandboxed beyond that timeout. Don't run untrusted tool code in a shared workspace.

Secrets and context

Two helpers, same shape, different storage. Neither takes a default — a missing key raises at call time, and kelam verify reports any reference no lane provides.

secret("NAME")Encrypted workspace secrets. Set with kelam secret-set (the value never touches your command line), listed by name only, never read back.
context("KEY")Non-secret settings. Resolution is workspace lane → the agent's context: → per-call -C key=value, later winning — so you can retune a tool with no code edit.
$ kelam secret-set BOOKING_KEY --env-var BOOKING_KEY
$ kelam secrets                                    # names only
$ kelam context -s hours="9-6 PT"                    # workspace lane
$ kelam context front-desk -s BOOKING_API="https://…"  # the agent's own lane
$ kelam context front-desk +12065550123 -s name="Maya"  # about one caller

All the lanes are fetched and seeded into every call before it connects — even when empty — grouped by source: what's known about the caller, the workspace's facts, the agent's own, and this call's (direction, language, the current time, any -C values).

Tests

One test per file in tests/, run by kelam test against the deployed version. Test calls are hidden from your stats and exports and don't count against call quotas.

turns — text only, free

type: turns
description: the agent greets and stays on-topic
turns:
  - user: "Hi, are you open?"
    expect:
      # exactly ONE assertion key per list entry
      - reply: gives a helpful, on-topic answer     # judged by a model
      - not_tool: transfer_call                     # it did NOT hand the call off
  - user: "Can I book for Saturday?"
    expect:
      - tool: open_slots

It's text-only, but it runs the agent's real compiled runtime — so it exercises the actual tools, including the ones that read history.

voice — a real call, $0

type: voice
caller: {persona: "a customer asking your hours; hang up once answered"}
direction: inbound
expect:
  - judge: the agent answered the caller's question
  - ended_by: caller

A genuine agent-to-agent conversation with a scripted persona. No phone network is involved, so it costs nothing to run in CI.

Testing what it remembers

A turns test can carry the world the call happens in — who is on the line (caller:), their past calls, texts and remembered facts (history:), and several separate calls in order (conversations:, each persisted before the next begins). That's how "does it remember me from last time" gets tested rather than assumed — including the case that matters most: an agent must not invent a past for a first-time caller.

kelam schema evals prints a catalogue of ready-to-run test files covering every built-in tool and the memory cases; kelam create writes the ones that fit the template it scaffolded, so a new agent starts with real coverage.