Skip to content

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Safe agent actions on a real SaaS — Flue + Open SaaS

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 chat sidebar acting on the live task list


Architecture

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
Loading

The write path (three hops):

  1. The browser calls the sendAgentMessage Wasp action — authenticated by the user's session cookie, exactly like any other operation.
  2. Wasp derives the conversation id and userId server-side, then delivers the message to Flue as immutable initialData, proving it's the trusted proxy with the server-only FLUE_SEND_SECRET.
  3. When the agent's tools act, they call back into Wasp's /api/agent/* routes with a service token + the X-User-Id header — 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.


Why two services (and not one)

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.


The security model

Every rule here is enforced by structure, not by prompt:

  • The model never chooses the tenant. userId arrives as Flue initialData — 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 userId in 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_SECRET that 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.

Live dashboard invalidating when the agent acts


Quickstart

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 agent

Open 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.


Repo layout

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)

Versions (last verified 2026-08-11)

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.


What we'd add next

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 sendAgentMessage pinch point (à la Agent Native's view-screen pattern).
  • 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.

The two faces of Flue

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 a local sandbox and opens maintenance PRs — so this repo demonstrates both faces side by side. See milestones.md (M10) and the "two faces of Flue" write-up in video-notes.md.

About

a flue agent integrated into a saas app -- example

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages