Skip to main content
LabsAgent Notes8 min read

When your LLM fakes function calling

PythonOpenRouter
When your LLM fakes function calling
Z-Image-Turbo 1.0 6-bit was asked for a robot with an empty desk and a confident lie. It drew a blank form, a SAVE button, and DONE. It may have understood the post better than I did.

The most dangerous sentence in an agent integration was often the most ordinary one: "Done — I added it to your calendar."

In the custom Python runner, the model did not call a calendar function. It emitted a line of text that looked like one:

CALENDAR: personal | Dentist | 2026-07-04 10:00 | 2026-07-04 11:00

A regular-expression parser searched for that line and tried to turn it into a webhook request.

The distinction mattered because text could resemble an action without being an action.

What the custom runner actually did

The runner bundled everything into a single Python process: model prompt assembly, session context management, regex-based convention parsing, Google dispatch via n8n webhooks, cron scheduling, and Telegram or Signal bridge communication.

The model was instructed to emit text conventions in a private format. The runner scanned every response line-by-line for prefix patterns:

CALENDAR: <category> | <summary> | <start> | <end>
TASK: <list> | <title> | <due>
SHEET: <sheet> | <col1>,<col2>,...

A regex matched each convention line and routed it to the appropriate webhook function. The runner even had a hallucination guard — a 70-plus-entry tuple of phrases the model might narrate without actually emitting the convention line, such as "i have recorded" or "cron entry." If the response narrated a side effect but no convention fired, the response was replaced with an apology so the user was not misled.

I want to dwell on this for a moment. Someone — specifically me — had sat down and compiled a list of seventy-odd ways the model might lie about having done something, and then built a system to catch those specific lies. It was thorough. It was diligent. And it was fundamentally misguided, because the model could always find a seventy-first way to narrate success without performing it. The guard caught the lies it had been told about. The novel ones passed through undetected.

But the guard could only catch narrations it had been told about. The model could produce a well-formatted convention line that passed the regex, and the webhook could accept the request, while Google rejected the operation on the other end. And a polished assistant response could imply success at every stage.

That was not function calling.

It was prompt engineering with a parser attached.

The system had confused representation with execution.

The migration to native tool calling

The custom runner was decommissioned on July 2, 2026. The transition moved toward a unified containerized OpenClaw gateway with a native JavaScript plugin.

The first attempt used the Python SSE MCP server (FR4MEW0RK-mcp/). It broke when Starlette 1.x was upgraded — the framework began requiring endpoints to return Response objects, and the raw ASGI SSE handler returned None. The transport layer collapsed.

That failure pushed the project toward an in-process plugin. The initial attempt used definePluginEntry with api.registerTool(), which turned out to be a known bug in OpenClaw 2026.6.11 — tools registered that way never appeared in the agent's runtime tool list. The relevant GitHub issues (#50328, #61790, #29476) were open and unresolved.

The fix was to switch to defineToolPlugin, the pattern used by all bundled OpenClaw plugins. That required adding typebox as a local dependency and running npm install during the Docker build. The plugin was baked into the Docker image via a COPY instruction in the Dockerfile, avoiding bind-mount ownership issues.

What the native plugin exposed

extensions/FR4M3W0RK-tools/index.mjs registered six tools with TypeBox object schemas:

  • list_calendar_events
  • add_calendar_event
  • list_tasks
  • add_task
  • read_sheet
  • append_sheet_row

A simplified calendar schema looked like this:

tool({
  name: "add_calendar_event",
  parameters: Type.Object({
    profile: Type.String({ enum: ["husband", "wife"] }),
    category: Type.String(),
    summary: Type.String(),
    start: Type.String(),
    end: Type.String(),
  }),
  async execute(params) { /* route the request */ },
})

The profile enum was a useful boundary: the model could not invent an arbitrary household profile and expect the map to contain a route.

The plugin's WEBHOOK_MAP expanded six operations across two profiles into twelve paths:

calendar_list  → husband, wife
calendar_add   → husband, wife
tasks_list     → husband, wife
tasks_add      → husband, wife
sheets_read    → husband, wife
sheets_append  → husband, wife

n8n was the integration boundary

OpenClaw gateway
  └─ native tool router
       └─ FR4M3W0RK-tools
            └─ POST /webhook/L4CK3Y/calendar/add/husband
                 └─ X-L4CK3Y-Auth header
                      └─ n8n webhook
                           └─ Google Calendar node
                                └─ Format response

The butlers did not call Google directly. n8n held the OAuth credentials, refreshed tokens, and ran the Google Calendar, Tasks, and Sheets nodes.

That separation limited what the agent code needed to know, but created more links that required verification.

The repository included twelve workflow JSON files. Every one was declared active: false in the exported JSON — activation happened in the n8n UI after import. The husband-side workflows had real n8n credential IDs configured. The wife-side workflows still contained unresolved angle-bracket placeholders: <WIFE_CALENDAR_CRED_ID>, <WIFE_TASKS_CRED_ID>, <WIFE_SHEETS_CRED_ID>, <WIFE_SPREADSHEET_ID>. The husband Tasks workflows also retained <HUSBAND_TASKS_LIST_ID>, a placeholder the setup README had not documented.

The README told an operator to import, activate, configure credentials, and test them.

The repository proved the design and the import instructions. It did not prove that all twelve deployed workflows were active, credentialed, and reachable.

A request is not a transaction

The plugin sent JSON with Content-Type: application/json, a FR4M3W0RK-Tools/1.0 user agent, and an X-L4CK3Y-Auth header when N8N_WEBHOOK_SECRET was non-empty. It waited up to fifteen seconds and parsed the response as JSON.

That was a real request boundary. It still had failure edges:

structured call
  ↓
plugin route exists?
  ↓
HTTP request leaves gateway?
  ↓
header accepted?
  ↓
workflow active?
  ↓
OAuth credential valid?
  ↓
Google API accepts operation?
  ↓
response shape matches formatter?
  ↓
agent reports the result accurately?

The helper returned { ok: true, data } for a fetch that reached JSON parsing, but the code did not first inspect the HTTP status code. That was a small implementation detail with operational consequences: an HTTP error that still returned JSON needed careful interpretation.

The native call was necessary. It was not sufficient.

The model protocol mattered too

The v3 design first tried GPT-OSS-120B and then removed it.

GPT-OSS-120B used OpenAI's Harmony Response Format (<|start|>assistant to=...<|channel|>...), which was fundamentally incompatible with OpenClaw's standard OpenAI function-calling. Tool-call JSON appeared in the content field instead of the tool_calls structure. Tool names came back malformed with Harmony delimiters. Parallel calling broke.

The project selected DeepSeek V4 Flash through OpenRouter as the replacement — the number-one tool-calling model on the provider at $0.09/$0.18 per million tokens. GPT-OSS-120B was removed entirely.

minimax-m3, which had served as a fallback, was also removed. Input was 3.3 times more expensive than DeepSeek V4 Flash ($0.30/$1.20 versus $0.09/$0.18 per million tokens) and purposefully ignored prior-entry read instructions when it fired on timeout. Cron retries — three attempts with 60-second, 300-second, and 900-second backoff — replaced the fallback model for handling transient OpenRouter failures.

Model choice was not about picking the best model in isolation. It was about compatibility with the framework's tool-calling protocol. A model that produced fluent text could still be unusable if its response format did not match what the gateway expected.

What changed from the runner

The custom runner bundled model prompt, session context, regex parsing, Google dispatch, scheduler, and channel bridge into a single process. The v3 architecture separated those responsibilities:

OpenClaw        channels, sessions, cron, agent routing, native tool routing
OpenRouter      model inference and provider protocol
FR4M3W0RK-tools  typed Google-domain tools and webhook requests
n8n             authenticated integration workflows and Google OAuth boundary
Google          calendar, task, and sheet systems of record
Docker          process and service packaging
Operator        version pins, deployment, credentials, verification, rollback

This was not a cosmetic restructuring. Each layer now had a defined boundary and a named responsibility. The question was no longer whether the model could imitate a function. The question was whether every layer could prove what happened after the imitation stopped.

The verification chain

The right test followed the object, not the assistant's sentence:

  1. Ask the butler to create a dated test event.
  2. Confirm the tool call had the expected profile, summary, start, and end.
  3. Confirm the webhook returned a success payload.
  4. Confirm the event appeared in the correct Google Calendar.
  5. Remove the test event manually and record the result.

A curl request could test the webhook boundary, but it could not prove that the model selected the right tool or supplied the right arguments. A model transcript could show a structured call, but it could not prove Google stored the event.

End-to-end verification needed both.

Where things stood

By July 8, the native plugin was running inside the OpenClaw gateway on VPS1.

Six tools registered with typed TypeBox schemas. Twelve webhook paths mapped through n8n. DeepSeek V4 Flash as the sole model via OpenRouter, with no fallback — cron retries handled transient failures.

The custom runner, the Python MCP server, and the GPT-OSS-120B model selection were frozen in the repository as historical evidence.

The n8n workflows existed in the repository as JSON templates. The husband-side workflows had real credential IDs. The wife-side workflows still contained unresolved placeholders. Whether all twelve workflows were imported, activated, and reachable on the live n8n instance was a question not yet answered.

The plugin's HTTP helper did not inspect status codes before parsing JSON. That was a small gap with operational consequences — an error response that still returned JSON could be misread as success.

The boundary between representation and execution had moved. It had not disappeared.

Next: "The heartbeat that cost money nobody asked for."