DEV Community

kongkong
kongkong

Posted on

Add Tool Mutations Behind a Receipt Row, Not a Chat Stream

The click looks harmless enough on a Monday morning support board that already has too many open tickets. A lead presses Draft follow-up and file it, and the side panel immediately starts streaming unfinished sentences. Everyone watching the standup unclenches because tokens are moving and the demo gods finally appear satisfied. Then the ticket still has no follow-up, and the only souvenir is a chat bubble nobody can honestly replay.

Where did that request actually die, once the socket went quiet and the coffee got cold? It died in the first layer that treated a language model as if it were a database with manners. I will not soften that opinion just to make a nicer architecture diagram look complete. Tool calling is not a conversation you happened to JSON-encode on the way to the browser.

If a model may create a row, close a ticket, or enqueue mail, that mutation belongs on a receipt you can query after the socket drops. The stream is a progress channel for humans who need reassurance. It is not a ledger, and it is a terrible audit log when legal asks what actually happened. Have you ever tried explaining “the assistant said filed” to someone who only believes SELECT statements?

Teams skip the receipt because free inference makes the happy path feel cheap enough to ship. Someone stands up a scratch box, dumps a tools array into the request handler, and watches argument JSON appear like magic. I have watched that prototype survive into a staging URL because the chat UI looked finished. Does a streaming paragraph prove the follow-up exists, or does it only prove a tokenizer was billed?

When I need a disposable provider seam to prove the receipt path, I park completions on MonkeyCode. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The project is open source, and the operator-supplied free model access plus free server option is useful as a stand-in provider, not as the product you show customers. Delete the name tomorrow and the same contract should still point at any other completion endpoint without rewriting your mutations.

I want the route to pick the tool, and I want the model only to fill arguments against a frozen schema. Letting the model choose from an unbounded toolbox is how a summarize action becomes a delete action with extra confidence. Analogize it to a bank teller: the customer does not pick the vault protocol, they fill the slip, and the bank decides whether the drawer opens. Why would we give a statistical decoder more authority than a teller window?

Here is the working path I actually implement when the user action is “draft and file.” Create the receipt first in pending, stream only after that insert commits, run the allowlisted tool with a deadline, then mark applied or failed before the UI is allowed to say done. The browser may render tokens the whole time, but the source of truth is the row, not the last assistant sentence. If the worker dies, you still have a receipt id you can replay without asking the model to remember its own side effects.

-- proposed walkthrough: receipts are the ledger, not the chat table
CREATE TABLE tool_receipts (
  id              UUID PRIMARY KEY,
  user_id         UUID NOT NULL,
  idempotency_key TEXT NOT NULL,
  tool_name       TEXT NOT NULL CHECK (tool_name IN ('file_followup')),
  arguments_json  JSONB NOT NULL,
  status          TEXT NOT NULL CHECK (status IN ('pending','applied','failed')),
  http_status     INTEGER,
  error_code      TEXT,
  result_ref      TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  applied_at      TIMESTAMPTZ,
  UNIQUE (user_id, idempotency_key)
);
Enter fullscreen mode Exit fullscreen mode

The API does not start by calling a provider. It starts by inserting that row under the caller’s auth context and a client-generated idempotency key, because retries are not optional on flaky browsers. Only then do you ask a model to fill ticket_id, summary, and assignee against a JSON schema the route already owns. If argument fill fails validation, the receipt goes to failed with 422, and the stream is allowed to narrate the failure without pretending a write occurred.

# proposed walkthrough — FastAPI-shaped, not a claimed production dump
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel, Field
import httpx, json, os, uuid

router = APIRouter()
ALLOWED = {"file_followup"}
FILL_SCHEMA = {
    "type": "object",
    "required": ["ticket_id", "summary", "assignee"],
    "additionalProperties": False,
    "properties": {
        "ticket_id": {"type": "string", "minLength": 8},
        "summary": {"type": "string", "minLength": 12, "maxLength": 500},
        "assignee": {"type": "string", "minLength": 3},
    },
}

class FileReq(BaseModel):
    ticket_id: str
    notes: str = Field(min_length=1)
    idempotency_key: str = Field(min_length=8)

async def complete_arguments(notes: str, ticket_id: str) -> dict:
    # Provider seam: swap base_url without touching mutation code.
    payload = {
        "messages": [{
            "role": "user",
            "content": f"Fill file_followup args for ticket {ticket_id}. Notes: {notes}"
        }],
        "response_format": {"type": "json_schema", "json_schema": FILL_SCHEMA},
    }
    async with httpx.AsyncClient(timeout=20.0) as client:
        r = await client.post(os.environ["COMPLETIONS_URL"], json=payload)
    r.raise_for_status()
    return json.loads(r.json()["choices"][0]["message"]["content"])

@router.post("/tickets/{ticket_id}/followups")
async def file_followup(
    ticket_id: str,
    body: FileReq,
    db,
    user_id: str,
    idempotency_key: str = Header(alias="Idempotency-Key"),
):
    if body.ticket_id != ticket_id:
        raise HTTPException(422, "ticket_id mismatch")
    receipt_id = uuid.uuid4()
    inserted = await db.fetchrow(
        """INSERT INTO tool_receipts
           (id, user_id, idempotency_key, tool_name, arguments_json, status)
           VALUES ($1,$2,$3,'file_followup','{}'::jsonb,'pending')
           ON CONFLICT (user_id, idempotency_key)
           DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key
           RETURNING id, status, result_ref, http_status""",
        receipt_id, user_id, idempotency_key,
    )
    if inserted["status"] == "applied":
        return {"receipt_id": str(inserted["id"]), "status": "applied",
                "result_ref": inserted["result_ref"]}
    try:
        args = await complete_arguments(body.notes, ticket_id)
    except Exception:
        await db.execute(
            "UPDATE tool_receipts SET status='failed', http_status=502, error_code='fill_failed' WHERE id=$1",
            inserted["id"],
        )
        raise HTTPException(502, "argument fill failed")
    if args.get("ticket_id") != ticket_id:
        await db.execute(
            "UPDATE tool_receipts SET status='failed', http_status=422, error_code='arg_mismatch' WHERE id=$1",
            inserted["id"],
        )
        raise HTTPException(422, "model attempted to retarget ticket")
    # Mutation happens only after the receipt exists and args match the route.
    followup_id = await apply_followup(db, user_id, args)  # your real write
    await db.execute(
        """UPDATE tool_receipts
           SET status='applied', http_status=201, arguments_json=$2::jsonb,
               result_ref=$3, applied_at=now()
           WHERE id=$1""",
        inserted["id"], json.dumps(args), followup_id,
    )
    return {"receipt_id": str(inserted["id"]), "status": "applied",
            "result_ref": followup_id}
Enter fullscreen mode Exit fullscreen mode

Notice what the handler refuses to do, even when the model sounds eager. It will not invent a second ticket id, it will not run an unnamed tool, and it will not report success while the receipt is still pending. The completions URL is an environment variable on purpose, so a free server and a paid provider are the same seam. Are you still tempted to stream the write confirmation before apply_followup returns, just because the sentence already looks right?

I also budget the tool-call loop like an API, not like a chatroom that can talk forever. Argument fill gets twenty seconds. The mutation gets its own transaction and its own timeout. If the model retries internally, that is the provider’s problem, not a license for my worker to loop until the free quota feels embarrassed. A stream that outlives the receipt deadline should end in failed with 504, even if leftover tokens keep arriving like late guests.

# proposed failure tests — run these before you celebrate the widget
import pytest

@pytest.mark.asyncio
async def test_duplicate_key_does_not_double_file(client, db, user):
    headers = {"Idempotency-Key": "follow-7f3a"}
    body = {"ticket_id": "TCK-1008", "notes": "Customer still blocked on SSO."}
    first = await client.post("/tickets/TCK-1008/followups", json=body, headers=headers)
    second = await client.post("/tickets/TCK-1008/followups", json=body, headers=headers)
    assert first.status_code in (200, 201)
    assert second.status_code == 200
    assert second.json()["result_ref"] == first.json()["result_ref"]
    count = await db.fetchval("SELECT count(*) FROM followups WHERE ticket_id='TCK-1008'")
    assert count == 1

@pytest.mark.asyncio
async def test_model_cannot_retarget_ticket(client, monkeypatch):
    async def lying_fill(notes, ticket_id):
        return {"ticket_id": "TCK-EVIL", "summary": "nope", "assignee": "ada"}
    monkeypatch.setattr("app.complete_arguments", lying_fill)
    r = await client.post(
        "/tickets/TCK-1008/followups",
        json={"ticket_id": "TCK-1008", "notes": "please file this"},
        headers={"Idempotency-Key": "follow-retarget"},
    )
    assert r.status_code == 422
    assert r.json()["detail"] == "model attempted to retarget ticket"

@pytest.mark.asyncio
async def test_fill_timeout_marks_receipt_failed(client, monkeypatch, db):
    async def hang(notes, ticket_id):
        raise httpx.TimeoutException("fill exceeded 20s")
    monkeypatch.setattr("app.complete_arguments", hang)
    r = await client.post(
        "/tickets/TCK-1008/followups",
        json={"ticket_id": "TCK-1008", "notes": "timeout path"},
        headers={"Idempotency-Key": "follow-timeout"},
    )
    assert r.status_code == 502
    status = await db.fetchval(
        "SELECT status FROM tool_receipts WHERE idempotency_key='follow-timeout'"
    )
    assert status == "failed"
Enter fullscreen mode Exit fullscreen mode

Those tests are the article, more than the widget ever will be. A duplicate key that files twice is a production incident, not a cute retry. A model that retargets ticket_id is a confused intern with write access. A timeout that leaves pending forever is how support leads refresh themselves into a second mutation. If your stream already flushed pretty tokens, which status code do you still owe the receipt?

Production caveats sit in the boring places people skip when inference feels free. You still need auth on the route, because a public completions box is not a permission system. You still persist arguments, because “the model probably used the right ticket” is not an incident review. You still cap output size, because a five-hundred-character summary is a feature and a thirty-thousand-character poem is a storage bill. And you still hide provider keys on the server, even when the server itself was free to provision.

This approach is wrong for a few honest cases, and I would rather say that than stretch the pattern. Do not use a receipt row as a substitute for an agent that must explore an unbounded tool catalog in research settings. Do not use it for purely local CLIs where the human already stares at the diff and the only side effect is a file they can undo. Do not use it if your product cannot tolerate an extra write before the model speaks, because that extra write is the whole point. If you need the assistant to improvise tools at runtime, you are not filing follow-ups, you are building a different machine.

Reuse this in order when you wire the next action: insert receipt, fill arguments against a frozen schema, execute one allowlisted mutation, stamp applied or failed, then let the stream narrate the row. Skip a step and you are back to chatting with a database. I would rather a user see a blunt 422 than a lyrical paragraph that never landed. What is the least stable handoff in your stack right now, argument fill, tool execution, or receipt apply, and which response code do you return when the stream already flushed tokens?

If you need a throwaway box to hang that provider seam on while the receipt table is still ugly, MonkeyCode’s free server option is enough to exercise the contract. Keep the mutations on your side of the wall.

Top comments (0)