Guides
Testing with mock mode
The two pieces you own — the catalog you registered and the endpoint you wrote — are the two pieces most likely to break. Mock mode exercises both without calling a model provider and without spending anything.
What it replaces, and what it does not
Mock mode swaps out exactly one component: the model. Everything either side of it is the same code that runs in production.
| Stage | In mock mode |
|---|---|
| Tenant resolution from the API key | Real |
| Catalog lookup and tool gating | Real |
| The decision of which tool to call | Scripted |
| The arguments it sends | Scripted — built from your schema, or Arguments |
| Argument validation against your schema | Real |
| The signed HTTPS call to your endpoint | Real |
| The SSRF and endpoint guards | Real |
| Write staging, approval policy, confirmation | Real |
| The audit trail | Real |
| The answer's block payload | Scripted |
| Tokens billed | Zero — no provider was called |
So a mock run proves your endpoint verifies signatures correctly, returns the shape you meant, scopes by tenant, and stages writes instead of executing them. It proves nothing about whether a model would phrase a good question, because no model ran.
Turning it on
Mock mode is a host setting, not a per-request flag. Run a separate instance for CI rather than enabling it on the instance your users reach.
{
"AI": {
"MockMode": {
"Enabled": true,
"Tool": "get_open_tickets",
"Reply": "Mock mode: this answer is scripted, not generated."
}
}
}
Or as environment variables, which is usually what a CI job wants:
export AI__MockMode__Enabled=true
export AI__MockMode__Tool=get_open_tickets
| Setting | Default | Meaning |
|---|---|---|
Enabled | false | Swaps the provider for the scripted model |
Tool | unset | Which tool the scripted model calls. Unset means the first tool it is offered |
Reply | a fixed line | The prose in the answer's leading text block |
If Tool names something that is not in the catalog for that turn, the scripted model falls
back to the first tool it was offered and logs a warning. It cannot reach outside your catalog,
because the same gate applies to it as to a real model.
What a mock turn returns
The same envelope a real turn returns, in the same block contract, carrying one of every block type — so a renderer that has quietly stopped handling a type fails visibly here rather than never being asked.
{
"content_type": "application/vnd.tether.blocks.v1+json",
"version": "tether.blocks.v1",
"title": "Mock mode",
"blocks": [
{ "type": "text", "id": "mock_text", "variant": "body", "markdown": "Mock mode: this answer is scripted, not generated." },
{ "type": "kpi", "id": "mock_kpi", "title": "Scripted metric",
"items": [{ "label": "Scripted value", "value": 1, "intent": "neutral" }] },
{ "type": "table", "id": "mock_table", "title": "Scripted rows",
"columns": [{ "key": "label", "label": "Label", "align": "left" }, { "key": "value", "label": "Value", "align": "right" }],
"rows": [{ "label": "Example row", "value": 1 }] },
{ "type": "ranked_list", "id": "mock_ranked", "title": "Scripted ranking",
"rows": [{ "label": "First", "value": 2, "rank": 1 }, { "label": "Second", "value": 1, "rank": 2 }] },
{ "type": "stacked_bar", "id": "mock_bar", "title": "Scripted split",
"segments": [{ "key": "a", "label": "Part A", "value": 1, "token": "series_1" }, { "key": "b", "label": "Part B", "value": 2, "token": "series_2" }] },
{ "type": "quick_replies", "id": "mock_replies", "replies": [{ "id": "q1", "label": "Ask something else" }] },
{ "type": "footnote", "id": "mock_footnote", "text": "Scripted answer. Called get_open_tickets, which returned: …" }
],
"meta": {
"mock": true,
"tool": "get_open_tickets",
"tool_result": { "tickets": [{ "id": "T-1", "opened_at": "…" }] }
}
}
meta.tool_result is what your endpoint actually returned, unabridged. That is the field to
assert against — the footnote trims long results for readability, meta does not.
meta.mock is true on every scripted answer. A surface that must never render a scripted
answer to a real user can refuse on that field alone.
Asserting a read handler
const res = await fetch(`${process.env.TETHER_URL}/v1/chat`, {
method: 'POST',
headers: {
'X-Api-Key': process.env.TETHER_SECRET_KEY,
'X-Tether-Scope-Id': '9',
'X-Tether-User-Id': '431',
'Content-Type': 'application/json',
},
body: JSON.stringify({ prompt: 'how many tickets are still open?' }),
})
const answer = await res.json()
assert.equal(answer.meta.tool, 'get_open_tickets')
assert.ok(Array.isArray(answer.meta.tool_result.tickets))
If your endpoint rejects the signature, returns the wrong shape, or leaks another tenant's rows, this fails. None of those need a model to catch.
Asserting the write gate
Point Tool at a write and the turn comes back staged, not executed:
assert.equal(answer.meta.tool_result.preview, true)
assert.equal(answer.meta.tool_result.confirmation_required, true)
assert.ok(answer.meta.tool_result.action_id)
Your endpoint receives a action.pending notification for the staged action and is never asked
to perform it. Confirming it is a second call — see
Your first write.
Related
- Validating your catalog in CI — catch a malformed catalog before it is registered
- Verifying signatures — the check mock mode exercises on every call
- Your first write — the staging flow in full