For developers
Let the assistant act for a signed-in user.
Out of the box, the assistant answers questions. With this integration it answers "what's my balance?" and does "reschedule my booking" — acting as one specific logged-in user of your product, without a real credential ever reaching the language model.
Prefer to learn by building? The worked example walks the whole thing through, end to end, for one concrete app — plus the failure modes to expect.
01 — Why
What this enables, and when you need it
The test is simple: does the question contain the word my?
A normal AI Action calls your system — enough for "what are your hours" or "look up order #4182", anything where the customer supplies the identifying detail. It breaks down the moment the answer depends on who is asking, because the widget only knows an anonymous session id. This integration is what closes that gap.
| The customer asks… | Needs identity? | Use |
|---|---|---|
| "What are your prices?" | No | Knowledge base |
| "Where is order 4182?" | No | A normal AI Action |
| "What's my plan and balance?" | Yes | This integration |
| "Cancel my Thursday booking" | Yes | This integration |
| "Add this word to my list" | Yes | This integration |
Three things, all on your side, all under your control: a mint endpoint, an exchange endpoint, and a small Bridge service. On the Scode side there is no code — just actions you register in the dashboard. The rest of this guide is those five steps in order.
02 — Architecture
Anatomy at a glance
One opaque token travels from your logged-in page, through Scode, to your Bridge — and a real credential never leaves your servers.
- Your web appyou
A signed-in user opens the chat. The widget quietly asks your API for a short-lived, opaque handoff token that means "this user started a chat".
- The Scode widgetscode
Forwards that token to Scode alongside each message, as
endUserToken. One extra embed attribute — nothing else about the widget changes. - Scode's backendscode
Holds the token in a server-side context and injects it into your action's request as a header via
{{ctx:end_user_token}}. The model never sees it; it is never logged. - Your Bridgeyou
Receives the header, swaps the opaque token for a real user token server-to-server, checks the requested operation against an allowlist, and calls your own API as that user.
- The reply comes backscode
Your API's result — including a business refusal like "too late to cancel" — flows back as data, and the assistant explains it in the customer's language.
The assistant never chooses a URL. It chooses from a short list of named operations you approved, each mapped to one route you verified. That single constraint is what makes it safe to put a language model in front of a user's account.
03 — Security model
Why an opaque token, and not just a JWT
The obvious shortcut is to pass the user's real session token through the widget. Do not. An AI Action's request template can only interpolate values the model supplies, so that design hands a live credential to a language model — and to anything that can influence it, including a prompt injection hidden in a document the model just read.
The handoff token is deliberately weak in four ways, and each one matters:
aud claim is checked on redemption. It is rejected everywhere except your exchange endpoint — it cannot be replayed against your main API.What Scode guarantees on its side
- The token resolves only through a separate
{{ctx:...}}namespace — never from model-supplied arguments — so a model cannot inject its own value even if it tries. - It is never persisted to the action log, never returned to the model, never written into conversation history.
- It renders into headers and the request body only — never a URL, so it cannot leak through access logs, proxy logs, or a
Refererheader. - It is released only to hosts you register on the platform's
IdentityAllowedHostslist. An action pointed anywhere else receives an empty header — so a mistaken or malicious action can't exfiltrate identity.
The threat model, stated honestly
✓ What is contained
- The model, and anything it reads, never touches a real credential.
- An action a tenant admin points at their own server gets no token.
- A stolen handoff token can't hit your main API — wrong audience.
- The Bridge only ever performs operations you allowlisted.
! What you still own
- If a handoff token leaks within its lifetime, the holder can invoke your allowlisted operations as that one user, for a few minutes.
- So: keep the allowlist minimal, keep the TTL short, and gate destructive operations behind a confirmation in your API.
04 — Reference
The handoff token, in detail
You can implement both sides in any language in an afternoon — there is no library to adopt. This is the entire wire format:
// The handoff token is NOT a JWT. It is a signed, opaque blob: base64url( payloadJson ) + "." + base64url( HMAC_SHA256(payloadJson, SECRET) ) // payloadJson — the only fields, nothing readable or reusable: { "sub": "a1b2c3d4-…", // the user id you will act as "aud": "scode-bridge", // audience — rejected anywhere else "exp": 1784562089, // unix expiry — keep it short (≈5 min) "jti": "80416893…" // unique id, so it can be one-time if you want }
Verifying one — the whole algorithm
// Verifying a handoff token — 20 lines, any language. No JWT library needed. function validate(token, expectedAud, secret) { const [body, sig] = token.split(".") const expected = base64url(hmacSha256(body, secret)) if (!constantTimeEquals(sig, expected)) return null // forged or tampered const p = json(base64urlDecode(body)) if (p.aud !== expectedAud) return null // wrong audience if (now() > p.exp) return null // expired return p.sub // the user id, verified }
Use URL-safe base64 (+→-, /→_, drop the = padding) so the token is safe in a header. Compare the signature in constant time — a byte-by-byte early-exit comparison leaks the secret to a patient attacker.
05 — Your side
Step 1 · The mint endpoint
One authenticated route that returns a token for the current user.
// POST /api/auth/support-handoff-token — mint for the CURRENT user // Put this where BOTH your short-lived access token AND your long-lived refresh // credential can reach it. See "the credential-lifetime trap" below — this is the // single most common way the whole integration silently dies. [HttpPost("support-handoff-token")] public IActionResult Mint() { // 1) fast path — the access token is still alive var userId = CurrentUserIdOrNull(); // 2) fallback — access token gone, but the session lives on in the refresh cookie. // Validate it READ-ONLY. Do NOT rotate: rotating could burn a token the SPA is // about to use, and reuse-detection would then log the user out. A support chat // must never be able to sign someone out. if (userId is null) userId = ValidateRefreshCookieWithoutRotating(Request); if (userId is null) return Unauthorized(new { code = "not_signed_in" }); if (IsBanned(userId)) return Unauthorized(new { code = "account_banned" }); if (IsAdmin(userId)) return Forbid(); // support path must never act as admin var token = HandoffToken.Mint(userId, aud: "scode-bridge", ttl: 300s, HANDOFF_SECRET); return Ok(new { token, expiresAtUtc = now.AddSeconds(300) }); }
The mint endpoint must stay reachable for as long as the session lasts. If it is authenticated only by a short-lived access token or cookie, it starts returning 401 the moment that expires — typically an hour in. The widget then silently sends no identity, and every action fails as an anonymous user while the person is still perfectly logged in.
It is invisible when it happens: the assistant simply starts saying it doesn't know who you are, and usually asks the customer for an internal id they have no way of knowing. This is the single most common way this integration breaks. Put the endpoint where your refresh credential is valid, or accept either credential — and test the expiry case before you ship.
CORS, precisely
The widget calls this cross-origin with credentials, which is stricter than a normal fetch. You need:
- An exact
Access-Control-Allow-Origin— the wildcard*is rejected for credentialed requests. Access-Control-Allow-Credentials: true.- Your session cookie readable from the calling page — the simplest setup is the same registrable domain with
SameSite=Lax.
06 — Your side
Step 2 · The exchange endpoint
Server-to-server only. It turns a valid handoff token into a real, short-lived user token your own API already understands.
// POST /api/support/exchange — server-to-server ONLY (the Bridge calls this) // Never expose it to a browser. It turns a handoff token into a real user token. [HttpPost("exchange")] public async Task<IActionResult> Exchange([FromBody] ExchangeRequest req) { // constant-time compare — a plain == leaks the secret one byte at a time if (!FixedTimeEquals(Request.Headers["X-Bridge-Secret"], BRIDGE_SECRET)) return Unauthorized(new { code = "bridge_auth_failed" }); if (!HandoffToken.Validate(req.HandoffToken, "scode-bridge", HANDOFF_SECRET, out var userId)) return Unauthorized(new { code = "handoff_invalid" }); var user = await FindUser(userId); if (user is null) return NotFound(new { code = "user_not_found" }); if (user.IsAdmin) return Forbid(); // second line of defence var (accessToken, exp) = Jwt.IssueFor(user); // your normal user token return Ok(new { accessToken, userId, expiresAtUtc = exp }); }
- Compare the shared secret in constant time.
- Verify signature, audience, and expiry — and reject on any failure without revealing which one failed.
- Refuse privileged accounts, twice — here and at mint. If an admin ever opens the support chat, the support path must not become a way to act with admin rights.
- Issue the shortest user token your API accepts. The Bridge uses it once, immediately.
07 — Your side
Step 3 · The Bridge
A small service — the only thing Scode ever calls. It exists to be a narrow, auditable door into your API, and nothing more.
// The Bridge — the ONLY thing Scode ever calls. Its whole job is to be a narrow, // auditable door into your API. A few hundred lines total. // The allowlist IS the security boundary. Every operation → one verified route. var allow = { // reads (no confirmation needed) get_profile: { GET, "/api/users/me" }, list_bookings: { GET, "/api/bookings/me" }, // writes — your own API still enforces every business rule book_session: { POST, "/api/bookings", forwardBody: true }, cancel_booking: { POST, "/api/bookings/{bookingId}/cancel", pathParams: ["bookingId"] }, } app.post("/op/:operation", async (req, res) => { const op = allow[req.params.operation] if (!op) return res.status(403).json({ ok: false, error: "operation_not_allowed" }) // unknown op → 403, NEVER a passthrough const handoff = req.header("X-Scode-Handoff") if (!handoff) return res.status(401).json({ ok: false, error: "missing_identity", message: "The user is not signed in. Ask them to sign in and retry —" + " do not ask them for an account or booking id." }) // 1) swap the opaque token for a real user token, server-to-server const { accessToken } = await exchange(handoff) // POST /api/support/exchange + X-Bridge-Secret // 2) fill {path} params from the body, then call your API AS that user const path = fillParams(op.path, req.body, op.pathParams) const r = await fetch(baseUrl + path, { method: op.method, headers: { Authorization: `Bearer ${accessToken}` }, body: op.forwardBody ? JSON.stringify(req.body) : undefined, }) // 3) relay the result — including a business refusal — back as DATA, capped so a // huge payload can't blow up the model's context const body = (await r.text()).slice(0, 8000) res.json({ ok: r.ok, status: r.status, data: safeJson(body) }) })
Five rules worth keeping
- Never a passthrough. An unknown operation is a 403, not a proxied request. The allowlist is the security boundary — the moment it forwards arbitrary paths, you have handed a URL chooser to a language model.
- Map to routes you verified. Check each path against your actual controllers, not what you remember them being. Assumed routes are a reliable source of silent breakage.
- Cap the response (8 KB is plenty). Whatever you return is fed straight into the model's context.
- Relay business refusals as data. When your API says "too late to cancel", pass that message through with
ok:falseso the assistant can explain it warmly — instead of surfacing a bare failure it will narrate badly. - Return an actionable message on missing identity — literally tell the assistant to ask the user to sign in and retry. Without it, the assistant improvises and typically asks for an internal id the customer can't possibly have.
08 — Your side
Step 4 · Embed the widget
Add one attribute to the standard embed. Everything else is unchanged.
<!-- on YOUR pages, where the user is already signed in --> <script src="https://ai.scode.iq/widget/scode-widget.min.js" data-api-key="sk_your_widget_key" data-base-url="https://api.ai.scode.iq" data-end-user-token-url="https://api.yourapp.com/api/auth/support-handoff-token" data-language="auto" defer></script>
What the widget does with it
// What the widget does with data-end-user-token-url, so you know what to build against: async function getToken() { if (cached && now() < cached.expiresAt - 30_000) return cached.token // reuse, minus 30s skew if (inFlight) return inFlight // single-flight: 2 sends → 1 mint inFlight = fetch(tokenUrl, { method: "POST", credentials: "include", // sends YOUR session cookie — this is the whole trick headers: { Accept: "application/json" }, }).then(handle) return inFlight } // 401 / network error → returns null → the widget just chats anonymously. // Cache is also hard-capped at 90s so an SPA account-switch can't keep the old token long. // SPA hook: when your user signs out or switches account WITHOUT a page reload, call window.ScodeChat.identify() // drop the cached token immediately
If the token call fails or the visitor is signed out, the widget simply sends no identity and behaves as an anonymous chat — so your public pages keep working untouched. There is no "half-broken" state.
If a user can sign out or switch accounts without a page reload, call window.ScodeChat.identify() at that moment (invalidateIdentity() is the same call under a more literal name). The widget also hard-caps the cache at 90 seconds as a backstop, but calling it explicitly closes the window immediately — important because a stale token would let the assistant act as the previous user.
Placing it inside your own product
A floating bubble is rarely what you want inside a logged-in application. The widget can render inside an element you control instead, so the chat becomes part of your layout — a support panel in a settings page, a tab in an account area, a dedicated route.
<!-- your layout owns the box; the chat fills it --> <div id="support-chat" style="height: 560px"></div> <script src="https://ai.scode.iq/widget/scode-widget.min.js" data-api-key="sk_your_widget_key" data-base-url="https://api.ai.scode.iq" data-end-user-token-url="https://api.yourapp.com/api/auth/support-handoff-token" data-mode="inline" data-target="#support-chat" defer></script>
Add data-mode="inline" and data-target pointing at your container (data-mode="fullpage" for a whole route). Two things worth knowing before you ship it:
- The container needs a height. The chat fills it. With no height we apply a default and log a note; we deliberately do not force a minimum, because that would overflow any container shorter than it and paint over your page.
- Rendering it late is fine. If the target does not exist when our script runs — the normal case in an SPA — we watch for it and mount as soon as it appears. If it never appears we log an error and fall back to the floating launcher, so your users still have a way to reach support. Pass
data-target-fallback="none"if you would rather have no chat at all than an unexpected bubble.
To drive it from your own UI entirely, add data-launcher="none" and call window.ScodeChat.open() from your button. ScodeChat.on('escalated', …) and on('message:received', …) let you mirror conversation state into your own analytics or UI.
If your app sends a CSP, it must allow the widget script and the API it talks to — script-src for the bundle's host and connect-src for https://api.ai.scode.iq (the chat streams over SSE). The widget injects no inline <style> — it uses constructed stylesheets — so it survives a strict style-src untouched. Worth checking while your policy is still report-only: a missing connect-src entry only breaks the chat once you enforce it.
09 — In the dashboard
Step 5 · Register the actions
Now tell the assistant what it may do. In the tenant dashboard go to AI Actions → New action and choose Connect your own system. Create one action per bridge operation.
A read action
{
"name": "get_my_profile",
"description": "Get the signed-in user's OWN profile: plan, balance, status. Use
when they ask about THEIR account. Arabic: حسابي، رصيدي، خطتي.",
"actionType": "api_call",
"endpointUrl": "https://bridge.yourapp.com/op/get_profile",
"httpMethod": "POST",
"headers": { "X-Scode-Handoff": "{{ctx:end_user_token}}" },
"responseTemplate": "{{response}}",
"parameters": []
}A write action — with a body and typed parameters
{
"name": "book_appointment",
"description": "Book an appointment for the signed-in user. First call list_providers
for a providerId. Your API validates availability and refuses with a reason to relay.",
"actionType": "api_call",
"endpointUrl": "https://bridge.yourapp.com/op/book_appointment",
"httpMethod": "POST",
"headers": { "X-Scode-Handoff": "{{ctx:end_user_token}}" },
// {{json:...}} quotes a value safely. A raw {{durationMinutes}} where the model
// supplies "thirty" would produce INVALID JSON; {{json:}} yields "thirty" or 30 correctly.
"requestBodyTemplate": "{\"providerId\":\"{{providerId}}\",\"minutes\":{{json:durationMinutes}}}",
"parameters": [
{ "name": "providerId", "type": "string", "required": true },
{ "name": "durationMinutes", "type": "integer", "required": true }
]
}The fields that matter
IdentityAllowedHosts.{{ctx:end_user_token}}. This one line is the entire integration on the Scode side.{{json:name}} for anything non-string so a model-supplied value can't produce invalid JSON.{{response}} feeds the whole reply to the model, or {{$.path.to.field}} plucks one value out.Then switch it on
- In Settings → AI, enable AI Actions for the tenant.
- Ask the Scode team to add your bridge host to
AiActions:IdentityAllowedHosts. Until then the identity header arrives empty by design — this is the safety default, not a bug. - Test each action with the built-in test button, then end-to-end as a real signed-in user.
10 — Design
Designing your operation allowlist
The allowlist is where you decide exactly how much reach to give the assistant. Treat it like an API surface you'll live with, because you will.
✓ Do
- Start with reads only. Ship "what's my…" first; add writes once you trust the logs.
- Give each operation a clear verb-noun name:
get_profile,cancel_booking. - Map one operation to one real, verified route.
- Keep path ids as
{pathParams}the Bridge fills from the body — never let the model build a URL.
✗ Don't
- Expose a generic
/op/proxy?url=…. That's the whole thing you're avoiding. - Add an operation "just in case". Every one is attack surface for the token's lifetime.
- Rely on the Bridge for permission. It adds reach; your API still enforces rules.
- Return more than the assistant needs — big payloads cost context and can leak fields.
A read that leaks is an information problem; a write that misfires changes a customer's data. Keep destructive operations (cancel, delete, pay) behind a confirmation step in your API, so even a perfectly-authenticated call needs a second signal before it commits.
11 — The craft
Writing descriptions the model actually follows
This is the highest-leverage paragraph in the whole integration, and the one teams skip.
When the assistant decides which tool to call, the only free text it sees is each action's name and description. The dashboard also has a "trigger phrases" field — those are not sent to the model. They help other parts of the product, but they do nothing for tool selection. If your users ask in Arabic, the Arabic has to be in the description.
✗ Weak description
- "Get appointments."
- Says nothing about when to reach for it, so the model guesses — and guesses wrong next to a similar action.
✓ Strong description
- "Get the customer's OWN upcoming appointments — date, time, provider, status. Use when they ask ‘do I have anything booked’, ‘when's my next appointment’, or شنو مواعيدي. Arabic: مواعيدي، حجوزاتي، عندي حجز."
- Say when NOT to use it. Overlapping descriptions are the main cause of the assistant picking the wrong operation.
- Spell out house terms. The model can't infer that your branded plan name, loyalty currency, or in-app feature means what it means — if a word only makes sense inside your product, define it right here.
- Name the fields the model must supply and where they come from ("first call
list_providersfor a providerId"). - State the authoritative field when your data has traps — e.g. "use
durationMinutes, not the raw column, which can disagree." - Editing a description changes behaviour. Re-test after every edit, and make sure your registration script actually pushes description changes — not just new actions.
12 — Prove it works
Testing
Three levels, cheapest first.
1 · The dashboard test button
Every action has a test button that fires it with sample arguments — no customer, no chat. It confirms the URL, method, and template render. Note it injects no identity token, so an identity-gated operation will come back 401 from your Bridge — that's expected here; it proves the call reached the Bridge.
2 · The chain, from a terminal
# Prove the whole chain from a terminal, no browser needed. API=https://api.yourapp.com ; BRIDGE=https://bridge.yourapp.com # 1) sign a user in (however your API does it), keeping the cookie jar curl -s -c jar.txt -X POST $API/api/auth/login -d '{"email":"…","password":"…"}' # 2) mint a handoff token as that user HT=$(curl -s -b jar.txt -X POST $API/api/auth/support-handoff-token | jq -r .token) # 3) call the Bridge with it — should return that user's real data curl -s -X POST $BRIDGE/op/get_profile -H "X-Scode-Handoff: $HT" # 4) no identity → must be refused, never served curl -s -X POST $BRIDGE/op/get_profile # → 401 missing_identity
3 · The failure test that matters most
A green happy-path is not enough — the failure that actually bites in production is a credential quietly expiring. Simulate it on purpose:
# THE test that catches the #1 production break BEFORE your users do. # Drop the short-lived access cookie, keep only the refresh cookie, then mint again. grep -v access_token jar.txt > expired.txt # simulate 60-min access-token expiry curl -s -o /dev/null -w "%{http_code}\n" \ -b expired.txt -X POST $API/api/auth/support-handoff-token # MUST be 200. If it's 401, your mint endpoint dies an hour into every user's session, # the widget silently sends no identity, and the bot starts saying "I don't know who you are".
When you test through the live chat, watch the timing. An action-backed answer that arrives in ~1 second is a cached answer, not a fresh call; a real action turn takes a few seconds (retrieval → tool call → reply). If a wrong answer keeps coming back instantly, it's a stale cache entry — purge the tenant's answer cache and it regenerates.
13 — Cutover
Going to production
Build and test against staging with scrubbed data — never production accounts. When you're ready to flip:
- Point the actions'
endpointUrlat your production bridge host, and updatedata-end-user-token-urlon the production embed. - Add the production bridge host to
IdentityAllowedHosts. Miss this and identity silently stops flowing — the header goes empty and every operation 401s, exactly as if you were signed out. - Rotate to production secrets — a distinct handoff secret and bridge secret, in environment variables, in no repository.
- Re-run the expiry test against production, as a real user.
- Confirm your API still enforces every business rule under the bridge's token. The bridge adds reach, never permission.
The most common cutover mistake is updating the actions' host but forgetting the allowlist — or vice-versa. Both must name the same production bridge host. Test one real operation end-to-end before you announce anything.
14 — When it misbehaves
Troubleshooting
| Symptom | Usual cause & fix |
|---|---|
| Bot says it doesn't know who you are, while you are signed in | The mint endpoint is returning 401 — almost always because it outlived its access cookie. Open it directly in the browser's network tab. Fix: mint where the refresh credential is valid. |
| Works signed-in on one page, not another | Cookie scope. The mint call is credentialed and cross-origin — check exact-origin CORS and Allow-Credentials: true, and that the cookie is readable from that page. |
Every operation returns missing_identity | The header arrived empty. Either the widget sent no token, or your bridge host is not on IdentityAllowedHosts. In the dashboard, the action's test button reaching a 401 confirms the URL is right. |
identity_rejected from the Bridge | The token arrived but your exchange refused it — expired (they're short-lived), wrong audience, or signed with the wrong secret. Reloading the page mints a fresh one. |
| A wrong answer keeps repeating; rewording it fixes it | A cached answer from when the integration was briefly broken. The answer cache keys on the question, not the user. Purge the tenant's answer cache; identity-bearing and action-driven answers aren't cached going forward. |
| The assistant picks the wrong operation | Descriptions overlap or are vague. Say plainly when not to use each one, and keep the operation list short. See §11. |
| A write action produces a malformed-body error | A non-string value interpolated raw. Use {{json:field}} so "thirty" or 30 both serialise to valid JSON. |
| The bot quotes a number that's wrong for this user | Two fields in your payload disagree and the model picked the wrong one. Name the authoritative field in the action's description. |
| An answer arrives in ~1 second | Cache hit, not a fast model. Useful for telling "stale" from "broken": a real action turn takes a few seconds. |
15 — Reference
Reference tables
Template placeholders
Anywhere in an action's URL, headers, or body:
end_user_token. Never from model arguments, never in a URL, never logged.responseTemplate: the whole response body, fed to the model.responseTemplate: pluck one nested field out of the JSON response.Widget embed attributes
https://api.ai.scode.iq.auto · ar · en.Bridge error codes (a suggested convention)
16 — FAQ
Questions that come up
Do I need an SDK?
No. It's plain HTTP on both sides — two endpoints and one small service in whatever stack you already run.
Can the model ever see the real user token?
No. The real token is issued inside your Bridge, used once, and discarded. Only the opaque handoff token ever travels through Scode, and even that is kept out of the model's view.
What if the same person is signed in on two tabs?
Fine. Each tab mints its own short-lived token from the same session; validating one never disturbs the other. The mint is read-only — it never rotates or consumes your session.
Can I do this on WhatsApp or Telegram?
Not yet — those channels have no logged-in browser session to mint from. This pattern is for surfaces where your user is already authenticated in a browser: your web app.
How many operations should I expose?
The fewest that cover what customers actually ask. Ship reads first. Each operation is live attack surface for the token's lifetime, so "just in case" is the wrong instinct.
Where's a complete example?
The worked example builds the whole integration for one concrete app, in order, and walks through the failure modes you're most likely to meet.