← blog

We Built a Landing-Page Agent That Can't Do Anything

Everyone is bolting an agent onto their landing page, and everyone is giving it hands. Tools, retrieval, CRM write access, calendar booking, a database connection "just for lookups". The pitch is that a capable agent converts better. The reality is that you have put a language model with credentials on the most hostile surface you own — a public page that anyone can type into.

We went the other way. Concierge is a landing-page agent that is deliberately powerless. No tools by default. No retrieval by default. No memory. It answers from one thing: a brief we digested by hand and baked into the system prompt on the server. Ask it something outside the brief and it says it does not know.

Capability is the attack surface

A prompt injection is only as dangerous as the thing it can reach. If your agent can query a database, injection is a data breach. If it can hit your CRM, injection is a write. If it can only emit sentences about a brief you wrote, the worst outcome of a successful jailbreak is an off-brand sentence.

Hardening a capable agent is like fitting a better lock to a door you left in the middle of a field. The lock is not the problem.

So the first design decision was subtraction, not addition. The default instance has zero tools. Not "tools behind a flag" — the registry is empty and the model is never told any exist.

What the server actually enforces

The widget holds no secret. It knows an endpoint URL and nothing else. Every constraint lives on the server, because a constraint the client can edit is not a constraint.

Structured error codes throughout — queue_full, rate_limited_ip, rate_limited_session, provider_circuit_open — because "something went wrong" is not an operational signal.

The part where we started opening doors

Constraint as a feature is a nice story right up until someone asks the agent to actually do something. Fran did. So we added capability packs, and every one of them is a door we are deliberately opening.

The rule we set was that a capability has to be a named, reviewable, server-side handler. Not open-ended function calling. The model sees names and JSON schemas; it never sees an execution path.

Two tools exist so far: capture_lead and handoff_human. The interesting one is capture_lead, because it has a side effect, and side effects are where an agent stops being a chatbot and starts being a liability.

capture_lead(args)   -> refused, returns a confirmation ticket
capture_lead(args + ticket) -> executes once
capture_lead(args + stale/forged ticket) -> refused
capture_lead(other args + ticket) -> refused (ticket is intent-bound)

The gate fails closed: a tool that has not been classified is treated as side-effecting, not read-only. Getting that default backwards is how you ship a tool nobody reviewed and find out later. Repeats of an already-confirmed effect return the first result instead of firing twice, and idempotency is scoped to the caller and the exact arguments rather than being global.

Generative UI without generated markup

The same subtraction applies to rendering. The agent can put UI in the conversation — a button group, a lead form, a product card, a handoff card — but it cannot emit markup. It selects a registered component name and supplies props against a schema.

Unknown component: refused. Unknown prop: refused, not passed through. Wrong type: refused, not coerced. Arrays are bounded, so the model cannot decide to render a hundred buttons. Over-long strings truncate rather than fail, because a verbose model should degrade, not break. Every UI event carries a mandatory text fallback, so the whole feature can fall back to plain chat.

URL props are the sharp edge — a component with an href is an XSS vector waiting for javascript:. Executable schemes are refused at validation on every URL field, and a UI action naming a tool the page has not allowlisted is refused too. Otherwise the UI layer becomes a way around the tool policy you just wrote.

What we actually verified

60 tests, all passing, against a fake provider so the default suite needs no secrets. tsc --noEmit clean. Build clean. The suite covers the boring-but-load-bearing cases: hostile client roles stripped, unregistered tools refused without executing, a hanging tool aborted, a throwing tool degrading to an answerable turn, a lead email masked before it reaches the audit log, forged confirmation tickets rejected.

There is also a concurrency smoke that reports real numbers rather than an exit code — status codes, completed streams, peak observed provider concurrency, final circuit state. That distinction matters more than it sounds. An earlier version of that smoke crashed on startup with a ReferenceError before issuing a single request, and had been recorded as passing. A gate that prints no assertions has not asserted anything.

A green status is a claim. Go and look at what the thing was supposed to produce.

Same lesson in the render layer: the component tests ran headless with no DOM, so the widget had never been rendered in a browser at all. Every test passed and the mobile and accessibility criteria were untouched by any of them. A gate set can be structurally incapable of testing the clause it is supposed to cover.

Where it is

The code is at github.com/yolo-maxi/concierge. A live instance answers on frontier.repo.box, running the build described here. The product's own page is concierge.repo.box: the widget in its corner is briefed on Concierge, a six-question interview produces a brief the server validates, a configurator exposes every theming token with the real widget answering from your brief in a short-lived sandbox, and an export panel emits the script tag.

If you want the short version: decide what your landing-page agent is allowed to do before you decide how clever it is. The clever part is easy. The allowed part is the product.