Signing in
Passkeys only
Teammates sign in with passkeys (fingerprint, face, device PIN or a security key). There are no passwords, no email codes and no third-party login service. Passkeys are phishing-resistant: each belongs to your desk’s hostname, and the browser won’t use it on any other site.- Owner: created on the first visit, with the
SETUP_TOKENsecret. Setup runs once; afterwards the setup page refuses. - Teammates: join through an invite link from Settings → Team. Each link carries a role (admin or agent), works once and expires after 7 days. The desk stores only a hash of the link’s token.
- More devices: each person adds passkeys under Settings → Passkeys while signed in.
- Recovery: if the owner loses every passkey,
/recoverwith the setup token registers a new one for the owner’s account. The setup token is compared in constant time.
Sessions
Signing in sets ajun_session cookie: a random token, HttpOnly, SameSite=Lax, Secure over HTTPS, valid for 30 days. The database keeps only its SHA-256 hash. Signing out deletes it. Removing a member from the team signs them out.
Roles
Each role manages only lower roles. See Team.
API tokens
Tokens for thejun CLI (jun_…) are created in MCP → API tokens and shown once. The desk stores only their SHA-256 hash. A token acts as the person who created it, works only for its own workspace, and only on the agent config and the CLI’s login check: it can’t read conversations, change settings, or read or change action keys. Revoking it takes effect at once. See jun CLI.
Requests to the desk
- CSRF guard. Every API request other than
GETmust be JSON (Content-Type: application/json) or a file upload with the desk’s ownX-Jun-Uploadheader. Neither can be sent by a form on another site. When the browser reports anOrigin, it must be the desk’s own. The one exception is the widget’s “want a hand?” request, which comes from your website, carries no cookies or tokens, and only returns whether to show an offer. - Errors come back as
{ "error": { "code", "message" } }without stack traces. - Secrets and tokens are stored as hashes wherever the desk only needs to check them: sessions, API tokens, invite links, visitor tokens.
The widget
Widget key and allowed websites
The widget key (wk_…) is public: it’s in your page source. On its own it lets anyone show your chat. List your sites under Chat widget → Behaviour → Allowed websites to stop that:
- The chat frame is served with a
frame-ancestorspolicy naming only those sites (and the desk), so browsers refuse to show it anywhere else. - The live visitor connection and the “want a hand?” request check the browser’s
Originheader against the list.
Origin and enforce frame-ancestors themselves, so a page can’t fake them. An empty list allows any site, which is handy while you try things out.
Visitor tokens
Each browser that chats gets a random visitor token. The desk stores only its hash. A visitor can read and write only their own conversations, and their live connection receives only what’s meant for them (a teammate’s invite, the result of identifying), never inbox events.Identified customers
The widget knows who a customer is only from a token your backend signs: an HS256 JWT with the workspace’s identity secret (Chat widget → Behaviour → Identify signed-in customers). Never from JavaScript on the page.- Only
HS256is accepted: nevernone, never a key named inside the token. expis required,nbfis honoured, with 60 seconds of clock skew.subis the user’s id.- Rotating the identity secret invalidates every token signed with the old one.
- Two identified customers are never merged with each other. An anonymous visitor who signs in joins the identified contact.
secrets claim, for actions with {user.secrets.NAME}. They’re kept only until the token’s exp (or JunDesk.logout()), never stored on the contact, never shown to teammates and never put in the AI’s prompt. See Identify users.
Consent
Withdata-consent="required", the widget stores nothing in the browser and doesn’t connect its live socket until your page calls JunDesk.consent(true). See Consent and privacy.
Debug context
The widget records recent JavaScript errors, failed requests, page visits, rage clicks and “stuck on a page after an error”, in memory, and sends them only when the visitor writes to you. It never captures:- request or response bodies, cookies or storage,
- what visitors type, or page text (a rage click keeps only the element’s tag, id, aria-label, name, role and a button or link label of at most 40 characters),
- query values (only their names:
?token=…).
sk_/pk_/rk_ keys, password=/token=/api_key=-style values and card-like numbers are masked. The desk applies the same masking again on the server, so a modified loader can’t slip them through.
The debug context reaches only your teammates (the conversation’s timeline) and the AI’s prompt. Other visitors never see it. The code your app passes to JunDesk.reportError() is shown to teammates and in issue drafts, never to the AI answering visitors. data-capture="off" turns capture off. See Debug context.
Internal notes
Notes (messages marked internal) are for teammates only. They’re never sent over the visitor’s connection, never returned by the widget’s API, and never included in the AI’s conversation history. Tags and topics are teammate-only too.The AI and your keys
- Action keys go only in request headers (
{secrets.NAME},{user.secrets.NAME}); the config checker refuses them in URLs and query strings, which end up in logs. - A key comes from a Worker secret
JUN_SECRET_NAME, or from one an admin pasted on the action’s page. Pasted keys live in the workspace’s Durable Object storage, never in the database, and are never returned; the desk shows only their last 4 characters. API tokens can’t read or change them. - If your API echoes a key back (in a response or an error), the desk replaces every key the request carried with
••••before the AI, the audit log or anyone sees it. - Every action call is logged next to the conversation, for teammates only. Customers see only the action’s
status:line, never its name, inputs or results. - The AI’s decisions (hand off, flag a problem) are tool calls, not words it types, so a customer can’t trigger them by writing a magic phrase.
- An action that isn’t fully set up (a missing key) is never offered to the AI.
Integrations
- GitHub and Linear. Credentials come from the
GITHUB_TOKEN/LINEAR_API_KEYsecrets, or from what an admin pasted in Settings → Issue trackers, kept in Durable Object storage like action keys (last 4 characters shown). Issues are filed only when a teammate clicks; the AI never files one. The issue text is masked again on the server before it’s sent. - Notifications. The Web Push signing key (VAPID) is generated on first use and kept in Durable Object storage; only its public key is ever returned. Push messages are encrypted for each device (RFC 8291). Device endpoints are shown only as hashes. A notification about internal note text goes only to the @mentioned teammate.
Where your data lives
Everything is in your Cloudflare account:
There’s no Jun server and no telemetry. Nobody else, including the Jun project, has access to your desk.
What leaves your account
Sign in with ChatGPT spends the signed-in person’s own ChatGPT plan. OpenAI’s terms cover plan usage for your own use, not a desk answering the public.
Licences
The desk is AGPL-3.0. The widget loader and the agent-config format are MIT. The source is on GitHub, so you can check every claim on this page against the code.Secrets and settings
Every secret and how to set it.
Consent and privacy
Running the widget behind a cookie banner.