Tether
DocsGitHub

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.

StageIn mock mode
Tenant resolution from the API keyReal
Catalog lookup and tool gatingReal
The decision of which tool to callScripted
The arguments it sendsScripted — built from your schema, or Arguments
Argument validation against your schemaReal
The signed HTTPS call to your endpointReal
The SSRF and endpoint guardsReal
Write staging, approval policy, confirmationReal
The audit trailReal
The answer's block payloadScripted
Tokens billedZero — 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
SettingDefaultMeaning
EnabledfalseSwaps the provider for the scripted model
ToolunsetWhich tool the scripted model calls. Unset means the first tool it is offered
Replya fixed lineThe 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.