WP Webhooks / Blog / AI integration
Article · AI integration

When Your AI Agent’s Prompt Lies About Its Tools

A real bug post-mortem: a class_exists check made our agent’s prompt advertise tools the executor silently dropped — and the three fixes that shipped in 2.2.0.

8 min 2026-07-14
#ai#agents#postmortem

TL;DR: We shipped a bug where our in-admin AI agent's system prompt claimed tools the executor could not run — and the failure was completely silent. Three lessons from the fix, now in Webhook Actions 2.2.0:

  • Derive every capability claim in the prompt from the same catalog the executor runs — never from a proxy like class_exists.
  • Never silently drop a plan step the model proposed. A thinned plan looks complete and ships broken; surface the drop to the user.
  • Make the agent read API contracts at build time instead of recalling them from training data — a one-call schema read ended an entire class of hallucinated fields.

/ The failure

How does an AI agent build break with no error?

The request was ordinary: "create a new user from a CF7 submission, use the WP REST API." The agent behind Build with AI did everything right. It read the captured Contact Form 7 payload, read the POST /wp/v2/users contract, saw that password is required but absent from the form, and proposed a six-step plan — including a Code Glue snippet¹ that generates the password before dispatch.

Then the trace log told a different story:

Trace — the model proposed 6 steps; the plan kept 4

// response_raw: the model's plan (abridged)
"plan": [
  { "id": "step_1", "ability": "create_webhook",   ... },
  { "id": "step_2", "ability": "assign_credential", ... },
  { "id": "step_3", "ability": "set_mapping",       ... },
  { "id": "step_4", "ability": "create_snippet",    ... },  // ← dropped
  { "id": "step_5", "ability": "assign_snippet",    ... },  // ← dropped
  { "id": "step_6", "ability": "test_dispatch",     ... }
]

// what the executor recorded for the same turn
"parsed_ok": true,
"plan_steps": 4

Two steps vanished between the model and the plan review — the two that injected the required password. Every surviving step ran green. The user got a finished build with checkmarks that could only ever answer 400 missing password on a live delivery. No exception, no log entry, nothing to bisect. That is the worst kind of bug an agent can have: the system did exactly what it was written to do.

/ Root cause

Where did the capability lie come from?

The dropped abilities belong to our Pro plugin, which registers them into the agent's ability catalog through a filter. On this site, Pro had quietly deactivated itself: its bootstrap requires a minimum version of the free plugin and bails out at plugins_loaded when the check fails. So far so good — defensive version gating working as designed.

The lie lived one layer up. The system prompt decides whether to tell the model "Pro Code Glue is available" with a capability check — and that check used class_exists on a Pro class. But Pro's Composer autoloader loads unconditionally, before the version gate. The class existed; the plugin behind it wasn't running:

PHP — the check that lied, and the fix

// BEFORE — true even when Pro bailed out at plugins_loaded:
// the autoloader is registered before the version gate, so the
// class exists while the ability filter never ran.
private function proIsActive(): bool {
    return class_exists( 'Pro\License\LicenseManager' )
        && ( new LicenseManager() )->isActive();
}

// AFTER — ask the executable catalog itself. create_snippet is
// registered exactly when Pro booted AND holds an active license,
// so the prompt can no longer promise what the executor lacks.
private function proIsActive(): bool {
    return isset( $this->registry->definitions()['create_snippet'] );
}

The result of the mismatch: the prompt advertised snippet abilities that were not in the catalog, the model (correctly!) planned with them, and the plan normalizer — which validates each step against the catalog — discarded the unknown steps on the way in.

The general shape of this bug is worth naming: capability drift. Any agent whose prompt is assembled from one code path and whose tools are registered by another will eventually see the two disagree — a plugin deactivates, a license expires, a feature flag flips, a module fails to boot. The only durable fix is to make the prompt a projection of the tool registry, never a parallel account of it.

If the executor can't run it, the prompt must not promise it. Capability claims are derived from the tool catalog — the same array the executor validates against — or they are lies waiting for a boot-order bug. — the rule we now build by

/ Silent drops

Why is silently dropping a step worse than failing?

Our plan normalizer was written defensively, and reasonably so: a language model can name a tool that never existed, so unknown abilities were skipped. The mistake was skipping them silently. A plan is not a list of independent conveniences — steps depend on each other. Drop the snippet that injects password and the surviving create_webhook + set_mapping steps aren't a smaller version of the build; they're a different, broken build wearing the same name.

Failing loudly would have surfaced this in the first test run. Instead the thinned plan executed cleanly and the defect moved downstream — to a live form submission, days later, on someone else's site. The fix keeps the defensive skip but makes it visible: the normalizer records every dropped ability name, and the turn shows a notice in chat before anyone runs the plan:

Chat notice — what the user now sees instead of nothing

Some proposed steps were removed because this site cannot run
them: create_snippet, assign_snippet. The remaining plan may be
incomplete. If these are Code Glue abilities, make sure Webhook
Actions Pro is installed, up to date and licensed.

The same notice catches genuinely hallucinated tool names — the case the silent skip was originally written for — which previously also vanished without a trace.

Cyberpunk illustration of a developer at a wall of code terminals facing a giant robotic arm that is sparking and breaking apart, tagged with red error labels reading "TOOL NOT FOUND", "INTERFACE FAULT" and "PROMPT INCONSISTENCY", under the neon headline "When your AI agent’s prompt lies about its tools".

/ Read, don't recall

Should the agent read contracts or recall them?

The password bug had a prequel. Weeks earlier, the same build request produced a plan with no password handling at all — because the model recalled the wp/v2/users contract from training data and recalled it wrong. Models are confident about popular APIs precisely because they've seen thousands of near-copies, current or not.

The fix was to stop asking the model to remember and give it a way to look: a get_rest_route_schema read ability that performs an internal OPTIONS request on the site's own REST route and returns the self-declared argument list with required flags — about 1.3 KB for wp/v2/users. The system prompt makes the read mandatory before any internal build. Since then, every trace shows the same pattern: the model reads the contract, sees required: ["username", "email", "password"], and plans around reality.

The honest-refusal path is the part we're proudest of. With Pro genuinely absent, the prompt now says so truthfully — and the model does exactly what it's told:

Trace — the free-tier plan, after the fix (abridged)

"The WordPress REST API requires a 'password' field to create
a new user, but your Contact Form 7 submission does not provide
one. On the Free plugin tier, I cannot inject a generated or
static password. This means the user creation will likely fail
[...] you would need Webhook Actions Pro with Code Glue."

plan: create_webhook (disabled) → assign_credential → set_mapping
plan_steps: 3   — nothing dropped, nothing promised, nothing fake

Same model, same request. The only variable is whether the prompt tells the truth about what the site can do — and a truthful prompt turns out to be the difference between a broken build with green checkmarks and an honest plan with a clear upgrade path.

Failure modeSilent (before)Loud (after)
Prompt vs tools disagreeclass_exists proxy — prompt advertises tools the executor lacksCapability claims derived from the executable ability catalog
Model proposes unknown stepStep skipped without a trace; plan looks completeStep still skipped, but named in a visible chat notice
API contract knowledgeRecalled from training data, confidently wrongRead per-build via an internal OPTIONS schema request
Missing required fieldDiscovered as a 400 on a live delivery, days laterHandled in the plan — snippet on Pro, honest refusal on Free

All three fixes shipped in Webhook Actions 2.2.0: catalog-derived capability claims, dropped-step notices, and contract reads for internal REST automations — alongside confirm gates for test deliveries that create real data.

/Footnotes
¹ Code Glue is the Pro feature that runs a small PHP snippet on the payload before dispatch — the supported way to inject a computed value (like a generated password) that the triggering event does not carry.
² The Webhook Actions plugin on WordPress.org: wordpress.org/plugins/flowsystems-webhook-actions. The plan-first agent loop and its trace log are covered in the architecture article.
FAQ

Common questions always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

What is capability drift in an AI agent? +
Capability drift is when an agent’s system prompt and its executable tool registry disagree about what the agent can do. It happens whenever the prompt is assembled by one code path and the tools are registered by another — a plugin deactivates, a license expires, a module fails to boot — and the prompt keeps advertising tools the executor no longer has. The durable fix is deriving every capability claim in the prompt from the same catalog the executor validates against.
Why is class_exists a bad capability check in WordPress? +
Because Composer autoloaders register classes before a plugin’s bootstrap logic runs. A plugin that bails out at plugins_loaded — a version gate, a missing dependency — leaves its classes loadable while none of its hooks or filters ever registered. class_exists then reports capability that does not exist at runtime. Check for the actual registered behavior instead: a hook, a filter result, or an entry in the registry the feature populates.
What should happen when an LLM proposes a tool that does not exist? +
Validate the step against the tool catalog and skip it — but never silently. Record the dropped tool names and show the user a notice before the plan runs. Plan steps depend on each other, so a silently thinned plan is not a smaller version of the build; it is a different, broken build that looks complete and fails downstream where it is hardest to debug.
How do you stop an AI agent hallucinating REST API fields? +
Give it a read tool that fetches the route’s self-declared contract at build time — in WordPress, an internal OPTIONS request returns every argument with its required flag — and make the system prompt mandate that read before any internal build. Models recall popular API shapes from training data, which is confidently wrong often enough that recalling should never be the default.
Is an honest refusal better than a best-effort AI build? +
Yes. With truthful capability context, the model states the limitation, proposes only the steps the site can actually run, and names the upgrade path — the user decides with full information. A best-effort build that silently omits a required piece ships green checkmarks and fails days later on a live event, which costs far more trust than a plain “this tier cannot do that”.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate