A showcase template: an AI assistant that can take real, authenticated actions inside a live multi-tenant SaaS — create, complete, and delete a user's tasks — without ever being able to act as the wrong user or run anything it wasn't explicitly handed.
The interesting part isn't that the agent works. It's why it can't misbehave: the safety lives in what the architecture makes possible, not in what the model is told. The model never sees a tenant id, never chooses one, and has no shell, just typed tools it can execute.
Built on Open SaaS (a Wasp SaaS boilerplate) + Flue (a server-side AI-agent framework). Two services, one security story.
The browser talks to the Wasp app the normal way; the agent lives in its own Flue service.
Every write takes three hops so identity is bound by the one party that can verify a session (Wasp). Reads stream straight from Flue over a short-lived, read-only token.
flowchart LR
subgraph Browser["Browser · localhost:3000"]
UI[ChatSidebar]
end
subgraph Wasp["Wasp app (Open SaaS) · :3001"]
OPS["sendAgentMessage /<br/>mintAgentReadToken<br/><i>session-authed actions</i>"]
API["/api/agent/* task routes<br/><i>service token + X-User-Id</i>"]
DB[("Postgres<br/>Tasks")]
end
subgraph Flue["Flue agent · :5173"]
AG["SaasAssistant<br/>+ 5 typed tools"]
end
UI -->|"1 · send message (session cookie)"| OPS
OPS -->|"2 · deliver + initialData.userId<br/>(FLUE_SEND_SECRET)"| AG
AG -->|"3 · tool calls<br/>(FLUE_SERVICE_TOKEN + X-User-Id)"| API
API --> DB
AG -. "read stream · SSE<br/>short-lived read token" .-> UI
The write path (three hops):
- The browser calls the
sendAgentMessageWasp action — authenticated by the user's session cookie, exactly like any other operation. - Wasp derives the conversation id and
userIdserver-side, then delivers the message to Flue as immutableinitialData, proving it's the trusted proxy with the server-onlyFLUE_SEND_SECRET. - When the agent's tools act, they call back into Wasp's
/api/agent/*routes with a service token + theX-User-Idheader — never a value the model chose.
The read path (direct): the browser mints a short-lived, per-conversation
read token (mintAgentReadToken action) and streams history + live updates
straight from Flue. That token can read one conversation and nothing else.
Flue ships as a Vite plugin with its own build; Wasp owns its own client/server build pipeline. You can't cleanly nest one build inside the other, so the agent runs as a separate process the Wasp app talks to over HTTP. That constraint turns out to be a feature: the trust boundary between "the app that owns the session" and "the service that runs the model" is a real network boundary, which is exactly where the security checks belong.
Every rule here is enforced by structure, not by prompt:
- The model never chooses the tenant.
userIdarrives as FlueinitialData— immutable creation data set only by the session-verified Wasp proxy and validated by a Valibot schema at HTTP admission. The model can read its own system prompt all day; there is no input that lets it change whose data it touches. - Tools close over the tenant. Each tool factory binds
userIdin a closure. It is never part of any model-facing input schema, so the model can't even express "do this for someone else." - Send and read are different credentials. Writing (delivering a message,
which asserts identity) requires the server-only
FLUE_SEND_SECRETthat no browser ever holds. Reading requires a short-lived HMAC token scoped to one conversation. The credential that lets you read must never be the credential that lets you say who you are. - Destructive actions are gated. Deleting completed tasks only becomes a callable tool after the user types an exact approval phrase — and the phrase is checked against the actual delivered message, not against anything the model produced.
- No sandbox, on purpose. This agent has no filesystem and no shell — it gets five typed, tenant-bound tools, never arbitrary code execution. Handing a multi-tenant web agent a shell would dissolve every guarantee above. Code execution is only safe when the environment is disposable — which is a different trust posture entirely (see "The two faces of Flue" below).
The throughline: reuse the same Wasp operations the human UI already calls, so the agent's authorization is identical to the human path — there's no second, weaker way in.
Prerequisites: Wasp ^0.25,
Node >= 22, Docker (Wasp uses it to run Postgres), and an
Anthropic API key.
From a fresh clone — four commands across three terminals:
git clone <this-repo> flue-with-opensaas && cd flue-with-opensaas
make setup # 1 · install agent deps + generate MATCHING dev secrets into both .env files
# → then paste your ANTHROPIC_API_KEY into agent/.env
make db # 2 · Terminal A: start Postgres (Wasp-managed Docker) — leave it running
make dev # 3 · Terminal B: migrate the DB, then serve the Wasp app
make agent # 4 · Terminal C: serve the Flue agentOpen http://localhost:3000, sign up, go to the demo app page, and ask the assistant to "add a task to buy milk" or "complete my first task." The new task appears in the list live, without a refresh.
Three terminals because there are three long-running processes (Postgres, the
Wasp app, the Flue agent) — Wasp's dev server wants its own foreground terminal,
so we don't hide it behind a process multiplexer. make setup generates the
three shared secrets once and writes the identical values into agent/.env
and app/app/.env.server — the historically easiest thing to get wrong when
wiring two services by hand. Run make help to see all targets. Procfile.dev
lists the app + agent services for a PTY-allocating runner like overmind.
agent/ # the Flue agent service (its own Vite build)
src/agents/ # SaasAssistant — system prompt, approval gate, tool wiring
src/tools/tasks.ts # task tools, each closure-bound to a userId
src/tools/schedule.ts # save_schedule — agent generates the app's day plan
src/app.ts # Hono mount: send-secret vs read-token split
src/auth.ts # read-token verification (HMAC, timing-safe)
app/app/ # the Open SaaS (Wasp) app
src/agent/ # the Wasp side of the bridge:
operations.ts # sendAgentMessage / mintAgentReadToken (session-authed)
api.ts # /api/agent/* task routes (service-token authed)
serviceAuth.ts # service-token + X-User-Id → AuthUser
ChatSidebar.tsx # the chat UI + the send/read split + cache invalidation
milestones.md # the build, one testable goal per milestone (M0–M10)
learnings.md # terse technical notes + API-drift log
video-notes.md # the same story told for a junior dev (learning-video source)
| Piece | Version | Notes |
|---|---|---|
| Wasp | ^0.25.0 |
the Open SaaS app framework |
| Node | >= 22 |
Flue runtime + CLI target |
@flue/runtime · @flue/cli · @flue/vite |
2.0.3 |
the agent service |
@flue/react · @flue/sdk |
2.0.3 |
the browser side of the stream |
hono |
^4.7.0 |
agent HTTP mount |
valibot |
1.4.2 |
tool + initialData schemas |
vite |
^8.0.14 |
agent build |
| Docker | any recent | Postgres for wasp start db |
Flue is young and moving fast. If an import or hook signature disagrees with the docs, re-fetch the doc page before improvising — drift is logged in
learnings.md.
Honest gaps, deliberately out of scope for a focused template:
- Websocket-broadcast invalidation. Today the browser refreshes queries by watching its own conversation stream for completed tool calls. If the agent acted from another device/tab, that tab wouldn't know. A Wasp server → client broadcast on agent mutations would close that.
- UI approval buttons. The destructive-action gate is a typed phrase; a real product would render an inline Approve/Cancel control instead.
- UI context awareness. The assistant has no idea what screen you're on or
what you've selected. At one screen that's fine; app-wide, you'd feed
navigation/selection state through the
sendAgentMessagepinch point (à la Agent Native'sview-screenpattern). - An AG-UI bridge. Flue has no AG-UI support today. An adapter translating AG-UI events ↔ Flue's durable-stream protocol would unlock CopilotKit's component ecosystem — though its frontend-tools / shared-state model cuts against this template's "the browser injects nothing" security story. A standalone project.
This template shows Flue's tenant-bound, typed-tools face: a long-lived, multi-tenant web service where safety comes from design, because the environment (real user data) can never be thrown away.
There's an opposite face — the one most Flue examples show — where an agent gets a real sandbox (filesystem + shell) and free rein, and safety comes from the environment being disposable (a throwaway CI runner). Same framework, opposite trust posture.
🚧 Coming in M10: a bundled, gardener-style CI maintenance agent (
.github/workflows/) that runs in an ephemeral runner with alocalsandbox and opens maintenance PRs — so this repo demonstrates both faces side by side. Seemilestones.md(M10) and the "two faces of Flue" write-up invideo-notes.md.
