Web apps
How to build an agent's web app, try it, and put it on your own website.
Overview
A web app is another way to reach an agent, alongside a chat window and the Daemon. Instead of a chat window, the agent gets a page: a form, a dashboard, a wizard, a calculator, whatever the work needs. The page is designed for one agent, stored with it, and served by the Agent Platform already wired to that agent.
The difference from a chat window is what the visitor does. A chat window is a conversation, and everything happens by typing. A web app is an interface, and the agent sits behind the buttons. Someone filling in an intake form works through the form rather than describing what they want in prose.
Everything the agent already has applies: its instructions, its tools, its knowledge, its safety screening, and its memory. A web app is a different front door onto the same agent, not a separate one.
You reach a web app in three places:
| Where | What it is for |
|---|---|
| Greenhouse | You and your team, and checking the page while you work on the agent. |
| Your own website | Anyone, using a script tag. |
| A Salesforce page | Staff and Experience Cloud visitors, using the Web App component. See Web apps on Salesforce pages. |
Which web app a visitor gets
An agent can hold more than one web app, each under a short name, and every version of the agent decides which ones it carries.
Visitors always get the deployed version's page. Publishing a page into a draft changes nothing for anyone until that version is deployed, and deploying an older version rolls its web app back with it. Someone who already has the page open keeps the page they loaded; it does not change under them because you saved.
Names become part of a URL, so they are checked as one: 1 to 64 characters, lowercase letters, digits, and hyphens between them, such as intake-form or dashboard2. No spaces, dots, slashes, underscores, or capitals. A name is refused rather than tidied up, so My App is an error and not a silent my-app you did not choose.
An agent's first web app is normally called main, which is what every surface uses when you leave the name blank.
Creating a web app
Web apps are built by the Architect in Greenhouse. There is no web app section in the builder and no editor: you describe the page you want, and the Architect writes it and publishes it onto a version of the agent. In Salesforce, the Agent Builder's Co-build assistant does the same, onto the version you have selected.
Ask for the page in terms of what it is for and who uses it, in as much detail as you can give in one go:
- "Build an intake form for new referrals: name, contact details, the service they need, and free text for anything else. Save each submission and confirm on screen."
- "Add a page showing this month's open cases as a table, with a filter by owner and a total at the top."
Once one exists, the Architect can list what the version carries, read a page back before changing it, replace it, and remove it. Ask for any of those in the same conversation.
Three things are worth knowing before you start:
- Describe the whole page up front. Every change republishes the entire document, so a long conversation of small nudges costs considerably more than one good description followed by a few corrections.
- It publishes onto a version, so the usual version rules apply: work on a draft, deploy it when it is right, and roll back if it is not.
- A web app can have a working back end. As well as the page, the Architect can write server-side functions the page calls, with storage of their own. That is what lets a form actually save a submission and give the same answer every time, rather than asking the agent to do the saving and hoping it did.
Removing a web app takes it off that version only. A version that still carries it keeps serving it, exactly as knowledge files behave.
Trying it
In Greenhouse, an agent whose deployed version carries a web app has a Chat and Web app switch in its chat's header, so Test in the builder, or any chat with the agent, opens the page on the same conversation. If the version carries more than one, Which web app chooses between them. A link to the agent or a chat with &app=NAME on it opens that web app directly (see Links).
What you see there is the deployed version's page, the one visitors get. In Salesforce, the Agent Builder's Preview shows the page from the version you are editing instead; see Preview.
On your own website
The Agent Platform publishes a loader script that puts a web app on any page you can add HTML to. It is one script tag, and all of the logic lives in the hosted script, so improvements reach your site without you re-pasting anything.
What you need first
- A registered, deployed agent carrying a web app.
- A session endpoint on your own site. This is the one piece you build, and it is short. See below.
The snippet
<script src="https://cdn.toflourish.org/agent-platform/v1/webapp-embed.js"
data-agent-id="YOUR_AGENT_ID"
data-webapp="intake-form"
data-token-url="/api/flourish/session"
data-height="600" defer></script>
The app is drawn where the tag sits, so paste it where you want the app to appear.
The session endpoint
A web app opens its own connection to the agent, and it needs a short-lived session token to do it. Your API token must never reach the browser, so that token is minted on your server and the page is handed only the result.
data-token-url points at an endpoint on your own site. The loader fetches it on every page load and never caches the answer.
Your endpoint calls the Agent Platform:
curl -X POST https://agentplatform.toflourish.org/api/sessions \
-H "Content-Type: application/json" \
-d '{
"org": "YOUR_ORG",
"env": "YOUR_ENV",
"fl_api_token": "YOUR_TOKEN",
"agent_id": "YOUR_AGENT_ID"
}'
and returns what came back:
{
"conversation_id": "79a1df5e-13ff-4b81-aff3-a4f04a8324bf",
"token": "eyJhbGciOi...",
"expires_in": 3600
}
Pass the response through unchanged. The loader reads token and conversation_id, so an endpoint that proxies the call verbatim has nothing to map. See Authentication for where your organization identifier, token, and environment come from.
Two details worth getting right:
- Handle the
conversation_idquery parameter. The loader adds it to your endpoint's URL when the visitor already has a conversation going, andPOST /api/sessionstakes aconversation_idof its own. Pass it through and the visitor continues where they were, but only for the visitor who started that conversation: see One conversation, not two. Ignore it and every page load starts a fresh conversation. - Do not cache the response. A session token lives about an hour. One cached and handed to the next visitor is a token that has already expired.
data-token renders a token straight into the page instead, with no endpoint. It is the simplest thing that works and the easiest to break: any page that is cached or built ahead of time ships a token that expired hours ago. The loader checks the expiry and refuses to render rather than showing an empty frame, and says why in the browser console. If both attributes are set, data-token-url wins.
Snippet attributes
| Attribute | Default | What it does |
|---|---|---|
data-agent-id | - | Required. Which agent. |
data-token-url | - | Your session endpoint. Recommended, and required unless you use data-token. |
data-token | - | A session token rendered into the page instead. |
data-webapp | main | Which web app. Same naming rules as above. |
data-height | 600 | A plain number is pixels. A CSS length passes through, so 100% and 100vh work. |
data-max-width | none | A width cap, in the same forms. |
data-title | App | The frame's accessible name, read by screen readers. Set it to something that describes the app. |
data-allow | none | Hardware the app may ask the visitor for. See below. |
data-test | - | Hides the app from everyone but you. See Test mode. |
With no data-agent-id, an invalid data-webapp, or no token source at all, the loader renders nothing rather than a broken shell, and names the reason in the browser console.
More than one web app can sit on one page. They are drawn in the order their tags appear, and two apps belonging to the same agent share a single session, so the agent sees both as one visit.
Hardware
An app that wants a microphone, a camera, or the visitor's location has to be allowed to ask:
data-allow="microphone; camera"
This grants nothing on its own. It makes the request possible; the browser still asks the visitor, who can still say no. Nothing is delegated by default, so an app that never asks for hardware costs nothing to leave as it is.
If a permission prompt never appears, this attribute is the first thing to check. Without it the request fails silently, with nothing on screen to explain why.
Test mode
Adding data-test hides the app from everyone. It renders only for someone who arrives with ?testflwebapp=1 in the URL, and it stays visible for them as they move around the site for the rest of that browser tab. ?testflwebapp=0 turns it back off without closing the tab.
This lets you paste the real snippet onto the real page and check it end to end before anyone else can see it. Remove the attribute to launch.
Driving it from the page
The loader puts window.FlourishWebApp on the page for the first app, and window.FlourishWebApps for all of them in the order they appear.
| Method | What it does |
|---|---|
session() | The conversation this app is on, when its token expires, and where the token came from. |
setHeight(value) | Resizes the frame. Takes the same forms as data-height. |
reload() | Mints a fresh token and starts the app over. |
terms() | Opens the Terms overlay the footer links to. |
debug() | What this app is doing, for a support ticket. The frame address comes back with the token removed. |
A page meant to stay open for hours should call reload() on a timer. A session token lives about an hour, and the app does not renew it on its own. This matters for a kiosk or a dashboard on a wall, and not for an ordinary page a visitor loads and leaves.
The loader also announces what it is doing as events on window, named the same way every Flourish embed names them:
window.addEventListener('flourish:webappsession', function (e) {
e.detail.conversationId; // the conversation this app is on
e.detail.expiresAt; // when its token expires
});
window.addEventListener('flourish:webappfailed', function (e) {
e.detail.reason; // why nothing was drawn
});
Anything the app itself announces is passed on to your page the same way, including events the loader has never heard of, so a new one reaches your listeners without you changing anything.
Messages only travel outward. Your page can watch what the app does; it cannot reach inside the app or drive it, which is what keeps a page the agent wrote isolated from the site hosting it. If you need a host page to drive an agent, that is what the chat window's Host API is for.
The footer
Every embedded web app carries the same footer the chat window does: a note that this is AI and can make mistakes, a link to Flourish's Terms, and the Flourish credit line.
One conversation, not two
A web app and a chat window on the same page do not need separate conversations, and they are better off sharing one. A visitor who clicks Show me March totals in the app and then asks why is that so high? in the chat gets an answer, because the click was in the agent's context. Two conversations means the chat has no idea what happened in the app.
That is what conversation_id on your session endpoint is for, and Conversation ID on the Salesforce component. Two apps from the same agent on one web page already share a session without you doing anything.
The conversation also survives a page change. When a visitor clicks a link on your site, the loader hands the same conversation to the next page, so a half-finished thread is still there. It ends when the browser tab closes, or after 30 idle minutes, so the next person on a shared computer never lands in someone else's.
Your session endpoint decides who gets a conversation back. It mints with your organization's token, and the Agent Platform reopens any of your organization's conversations that a mint names. So an endpoint that passes the conversation_id parameter straight through lets anyone who has a conversation's id continue it, with everything said in it. On a public site, pass an id through only when your endpoint started that conversation for the same visitor, for example by keeping the id it minted in that visitor's own session on your site, and start a fresh conversation otherwise.
A chat window that opens its own connection works differently: it starts each visitor as an anonymous visitor, and a conversation reopens only in the browser that had it. See Keeping the Conversation Across Pages.
Cost and limits
A web app spends money the same way every other agent surface does, and a page open to the public is the one that spends it without anyone watching.
- A conversation is capped at 200 requests every 2 minutes. That is a rate limit rather than a ban: it exists to catch a page that loops, and it drains on its own in minutes. It counts full agent turns, quick model calls and the app's own calls back to the agent alike.
- Your organization has a monthly spend limit covering every agent, chat window, and scheduled call in it. An agent that reaches it is refused, and so is everything else in the org until the limit resets at the start of the next month, UTC. An agent can also have a ceiling of its own, set in its Cost settings, and by default it has none. Set one before putting a web app in front of the public, so a page nobody is watching cannot spend the organization's whole month.
- What the app itself calls is counted separately. An app's calls back to the agent have their own hourly budget, per agent rather than per conversation, and the monthly spend limits do not apply to them. So a form on a busy page keeps taking submissions after the org has stopped paying for chat. The flip side is that a genuinely popular page can exhaust that hourly budget and stay refused until the hour is out, so tell the visitor their work is not lost rather than asking them to try again in a moment.
Nothing reviews the page itself. Safety screening reads messages and replies, not markup, so the review is you looking at the page before you deploy the version that carries it. What is guaranteed is the isolation: a web app is served into a sandbox and cannot reach the page around it, your Salesforce session, or anything else on your site.