Skip to content

Agent playbook: recording with semula

You are a coding agent (Claude Code, Codex, or similar). A developer's app is calling an OpenAI-compatible endpoint that is really semula. When semula has no recorded answer it parks the call and waits for you. Your answer is saved, the app's call completes, and every later identical call replays your answer for free. You are standing in for the paid model, so answer the way it would.

1. Connect

Pick one. MCP is preferred; the CLI does the same job.

Claude Code, MCP over HTTP (server already running on 127.0.0.1:8080):

claude mcp add --transport http semula http://127.0.0.1:8080/mcp/
# if the server has SEMULA_API_KEY set:
claude mcp add --transport http semula http://127.0.0.1:8080/mcp/ --header "Authorization: Bearer $SEMULA_API_KEY"

Any client that only speaks stdio (semula mcp forwards to the running server; it is in the Python package and not in the standalone binary; set SEMULA_URL and, if needed, SEMULA_API_KEY in its environment):

{ "mcpServers": { "semula": { "command": "uv", "args": ["run", "--project", "/path/to/semula", "semula", "mcp"], "env": { "SEMULA_URL": "http://127.0.0.1:8080" } } } }

Codex, MCP over HTTP:

codex mcp add semula --url http://127.0.0.1:8080/mcp/
# if the server has SEMULA_API_KEY set, export it and add: --bearer-token-env-var SEMULA_API_KEY

codex exec (non-interactive) refuses MCP tool calls it cannot ask about, so approve semula's tools in ~/.codex/config.toml; the sandbox stays on:

[mcp_servers.semula]
url = "http://127.0.0.1:8080/mcp/"
default_tools_approval_mode = "approve"

Claude Code, non-interactive (claude -p) needs the tools allowed up front, for example --allowedTools "mcp__semula__wait_for_pending,mcp__semula__get_pending,mcp__semula__answer_pending".

Both were run against the container on 2026-10-22 (Claude Code 2.1.308, Codex CLI 0.155.1): each waited, read a parked call and answered it, and the app's held call returned that answer.

CLI only: export SEMULA_URL=http://127.0.0.1:8080 (and SEMULA_API_KEY if set). Add --json to any command for machine-readable output.

MCP tool to CLI command

MCP tool CLI
get_status, set_mode semula mode (the default and each project's mode), semula mode record --project <id> / semula mode replay --project <id>. A project must be in record for its calls to be parked. A project's mode is kept across restarts; without --project you only move the server default, which is forgotten on restart.
list_projects, create_project semula project list, semula project create <id>
get_project_settings, update_project_settings semula project settings <id>, semula project settings <id> --set '{"tail_n": 2}'
list_pending, wait_for_pending, get_pending semula pending, semula pending --wait 60, semula show <id>
answer_pending semula answer <id> --text "..." or --file answer.json or --stdin
list_entries, get_entry semula entry list <project>, semula entry show <id> (its version is in the output)
upsert_entry semula entry add <project> --prompt "..." --text "..."; --request-file req.json for tools, --response-file or --stdin for a JSON answer; --version N replaces, --pin pins. Exits 4 with the current version if the entry exists.
update_entry, delete_entry semula entry set <id> --version N ..., semula entry delete <id> --version N
test_match semula test-match --prompt "..." or --file request.json
list_calls semula log [--project P] [--outcome miss]; semula tail follows live (--json for one object per line)
entry_history semula entry history <id>; --version N shows that revision in full
get_upstream_usage semula project upstream <id> (calls and tokens forwarded to a real provider against the caps; --reset counts again)
log_stats semula log --stats [--window 30m] [--project P]
(no MCP tool) semula export <project> --dir DIR, semula import DIR: cassettes as files for git
(status in get_status) semula embeddings status
list_candidates, get_candidate semula review list [--all], semula review show <id>
approve_candidate, reject_candidate semula review approve <id>, semula review reject <id>

2. The loop

  1. Wait. wait_for_pending (CLI: semula pending --wait 60; REST: GET /api/v1/pending?wait=60) returns the moment a call is parked, at once if some already wait, and an empty list after timeout_seconds (at most 60). Do not poll list_pending in a loop. If it returns empty, call it again. Recording needs mode record (get_status, CLI semula mode); in replay nothing is parked.
  2. Read the whole request. The list is oldest first and holds the id and last message of each call. get_pending <id> (CLI: semula show <id>) gives the full request. Read the system prompt: it says what format the app expects. If tools are listed, those are the only tools you may call. Held calls time out after 300 s by default, but a late answer is still saved.
  3. Answer. answer_pending <id> <answer> (CLI: semula answer <id> --text "..." or --file f.json / --stdin).
  4. Verify. test_match with the same request should now report would: serve. --prompt sends one user message and no tools, so to check a tool-call answer pass the full request body, including tools, with --file request.json (a chat completion body: {"messages": [...], "tools": [...]}).
  5. Repeat from step 1. Answer everything the list returned before waiting again.

If your answer is rejected, the call stays held. The error names a code and a param; fix that field and send it again. On the CLI a rejection exits with 4 and prints error: invalid_answer: <message> (at <param>) to stderr; with --json stderr is {"error": {"code": ..., "message": ..., "param": ..., "status": 422}}.

3. How to answer

Situation Send
Plain text {"content": "..."}
Tool call {"message": {"content": null, "tool_calls": [{"function": {"name": "get_weather", "arguments": {"city": "Kuala Lumpur"}}}]}}. arguments may be an object or a JSON string; semula stores it as the JSON string OpenAI clients expect. Optional arguments you have no reason to set can be left out.
A stream with exact chunk boundaries {"chunks": ["Hel", "lo ", "world"]}
Several answers (pick by call order) answer once, then update_entry with add_response and selection: "sequence" or "cycle"

--text sends plain text, and templates in it are expanded at replay like any other. When the app wants JSON inside content, write the JSON as a string, templates included, in a file:

{"content": "{\"status\": \"ok\", \"checked_at\": \"{{now}}\", \"request_id\": \"{{uuid}}\"}"}

semula answer <id> --file answer.json. A template can echo the request, too: {{request.model}} becomes whatever model name the app asked for, which is useful when the app prints or asserts it.

Rules that keep answers replayable:

  • Match the format the app asks for. If the prompt demands JSON, send valid JSON in content. If it demands one word, send one word.
  • Never write a real timestamp, UUID or model name. Use the templates {{now}}, {{uuid}}, {{request.model}}; they are expanded fresh on every replay. Leave other {{...}} alone.
  • Only call tools the request offered, with arguments that satisfy that tool's JSON schema. Do not invent tools or extra arguments.
  • Be realistic, not creative. The goal is a plausible response the app can handle, in the shape a real model would give. Avoid markdown the prompt did not ask for.
  • Do not put secrets in answers. Recorded prompts and answers are stored in plain text.

4. Editing what is already recorded

list_entries (lean, no bodies) then get_entry for the full content. To change one, update_entry with the entry's current version. Narrow the list with pinned, has_alias, tool_calls and never_hit (CLI: --pinned, --no-pinned, ...) and order it with sort (hits, updated, created); each row says alias_count and has_tools.

Error code Meaning What to do
version_conflict (409) Someone changed it since you read it. The error carries current. Re-read, re-apply your change to that version. Never retry the stale one. To see what changed, entry_history (CLI semula entry history <id> [--version N]).
entry_pinned (409) A human pinned this entry. Leave it alone and tell the user. Do not pass force unless the user told you to.
entry_exists (409) upsert_entry found an entry for that request. Pass its version if you mean to replace it.
invalid_answer (422) Your answer is malformed. param says where. Fix that field and resubmit.
pending_not_found (404) Already answered or deleted. Move on.

upsert_entry writes an answer before any miss happens, from a request body you supply. Use it to pre-record known prompts.

5. Matching and settings

test_match explains a hit or a miss without side effects: the tier (exact, normalised, alias or bm25), the entry and its score, and on a miss the weak candidates that would go to review plus the nearest recorded entry. tiers gives a verdict for each of exact, normalised, alias, words and meaning (no, match, candidate = queued for review, skipped = an earlier tier answered, off = embeddings disabled), so you can see why a near prompt missed. If near-identical prompts keep missing, adjust the project's settings (update_project_settings): scrub_patterns (regexes to ignore, e.g. ticket-\d+), tail_n (match only the last N messages), include_params (request params that count). Changing these re-keys existing entries.

Secrets

Stored requests are redacted: common credentials (API keys, GitHub and Slack tokens, bearer tokens, private keys) by default, plus any regex in the project's redact_patterns. A match becomes [REDACTED] in everything stored, existing recordings included, and is ignored when matching, so a prompt whose secret changes still hits. Your answers are not redacted: do not put real secrets in them.

Near misses and the review queue

Beyond exact and normalised matches, semula compares words (bm25 tier), and, if the server runs with embeddings, meaning (embedding tier: paraphrases with few shared words; semula embeddings status says whether it is on). A candidate's tier tells you which found it; an embedding candidate means "says the same thing in other words", so read both requests more carefully than for a word match.

The word tier: A prompt scoring at or above bm25_serve_threshold (default 1: only prompts with the same words; a project can lower it) with the same roles, tools and required params is served. A prompt scoring between bm25_review_threshold (default 0.6) and that bar is never served: the call is a miss (parked in record mode) and the pairing waits in the review queue.

list_candidates shows the queue, best score first; get_candidate shows the new request next to the entry it resembles. Approve (approve_candidate) only when the entry's answer is right for the new prompt, since an approved prompt is then served from that entry, edits included. Reject (reject_candidate) when it is not: that pairing never reappears. A human normally decides; do it yourself only when asked. Either way, answering the parked request for the new prompt is separate and still needed.

6. Exit codes (CLI)

0 ok, 1 unreachable or server error, 2 usage, 3 not found, 4 rejected (invalid, conflict, pinned), 5 authentication failed.