Sign in with Wire
Sign in with Wire lets a person sign in to your own app with their Wire account. Your app learns who they are to your agent, and your server can call your agent’s tools as that person, with no container URL and no API key in the browser.
It is for agents whose manifest declares access. The sign-in is finished on your server, with a client secret. If your manifest does not declare access, nothing on this page applies and connectInBrowser() works as before.
You need @usewire/sdk 0.17.0 or later. The helpers here are on the @usewire/sdk/agent entry and run on Node 18+, Cloudflare Workers, Bun, and Deno.
What your app gets
Section titled “What your app gets”A completed sign-in gives your server three things.
| You get | What it is | Where it lives |
|---|---|---|
| An identity | The person’s id for your agent (au_…), and their email, name, and picture if your manifest asks for them | Your user record |
| An access token | What your server presents to your agent’s endpoint to call your tools as that person | Your server only |
| A refresh token | A way to get a new access token later, when you ask for offline_access | Your server only |
The id is the same agentUserId your install webhooks and agent API use. It is stable for that person and your agent, another agent gets a different id for the same person, and it never reveals their Wire account.
Declare what you ask for
Section titled “Declare what you ask for”The access block of your manifest says how far your app may reach into a person’s container, and which identity fields it wants.
import { defineManifest } from '@usewire/sdk/agent';
export const manifest = defineManifest({ manifest: 1, app: { id: 'someday', name: 'Someday', version: '2.0.0', privacy_policy_url: 'https://someday.example/privacy', }, access: { level: 'write', read: 'Shows the places you saved.', write: 'Saves places you add.', identity: ['email'], }, // objects, tools, ...});level | What your app may do in the person’s container |
|---|---|
none | Nothing. Your app learns who the person is and no more. |
read | Call your tools that do not change a record. |
write | Call all of your tools. |
The rest of the block:
readandwriteare one line each, in your words, saying what that access is for. Plain text, at most 200 characters, no links or markup. Givereadwith levelreadorwrite, andwriteonly with levelwrite.identitylistsemail,profile(name and picture), or both. Leave it out and your app gets the id alone.app.privacy_policy_urlis required as soon asidentityasks for anything. It must be anhttpspage on your own domain.
Two rules follow from what your tools do:
- A manifest whose tools send container records to your own server may not declare
none. A tool does that when anafteraction uses the tool’s output. - At any level, your app never reads the container’s activity log or its uploaded files.
defineManifest checks all of this locally, the same way Wire does when you register the manifest.
What the person sees
Section titled “What the person sees”The first time someone signs in to your app, Wire shows a sign-in screen under your agent’s name. It states, in Wire’s words, what your app may do in their container at the level you declared, followed by your own read and write lines, marked as coming from you. It lists what your app is given: the id, and each identity field you asked for. It links to your privacy policy, showing the address’s host as the link text.
The person approves once. On later sign-ins from the same app they are sent straight back to you.
If a new version of your manifest raises the level or adds an identity field, the person is shown the screen again before it applies. Lowering the level, dropping a field, or rewording a line does not ask them again.
Signing in to your app needs a Wire account. Someone trying your agent without an account cannot sign in this way.
Set up your agent
Section titled “Set up your agent”Three things on your agent’s page in Wire, all before you ship.
- Turn on your agent’s endpoint. Under Connect, turn the endpoint on. A sign-in connects the person to it, and it is what your server calls afterwards. While it is off, Wire refuses the sign-in and says so.
- Create a client secret. Under Sign-in, create a client secret. It is shown once. Put it in your server’s secret store. Creating a new one stops the old one at once.
- Add your callback address. Add your server’s callback URL to the agent’s redirect addresses. Wire only sends people back to an address on that list, matched exactly.
The client secret must never reach anything a person can read: not a web page, a worker in the browser, or a mobile or desktop app.
Sign people in on your server
Section titled “Sign people in on your server”The flow is an authorization code flow with PKCE. WireSignIn builds the request, exchanges the code, and verifies the result.
import { WireSignIn } from '@usewire/sdk/agent';
const wire = new WireSignIn({ agentId: 'someday', clientSecret: process.env.WIRE_CLIENT_SECRET!, redirectUri: 'https://someday.example/auth/wire/callback',});
// 1. Start: send the browser to Wire.app.get('/auth/wire/start', async (req, res) => { const request = await wire.createAuthorizeRequest({ scope: ['email', 'offline_access'] }); req.session.wire = { state: request.state, codeVerifier: request.codeVerifier, nonce: request.nonce }; res.redirect(request.url);});
// 2. Callback: Wire sends the browser back with ?code=...&state=...app.get('/auth/wire/callback', async (req, res) => { const kept = req.session.wire; if (!kept || req.query.state !== kept.state) return res.status(400).send('This sign-in did not start here.'); delete req.session.wire; if (typeof req.query.code !== 'string') return res.redirect('/?signin=cancelled');
let tokens; try { tokens = await wire.exchangeCode({ code: req.query.code, codeVerifier: kept.codeVerifier, nonce: kept.nonce }); } catch { return res.redirect('/?signin=failed'); // a code works once: start again }
const user = await upsertUser({ wireId: tokens.identity.agentUserId, email: tokens.identity.email }); await saveTokens(user.id, tokens);
req.session.regenerate(() => { req.session.userId = user.id; res.redirect('/'); });});What each step does:
createAuthorizeRequest()returns the URL to send the browser to, plus astate, acodeVerifier, and anonce. Keep those three in the person’s server-side session.- Check
statewhen the browser comes back. It must be the one you kept. exchangeCode()trades the code for tokens, authenticated with your client secret. It verifies the ID token before it returns: signed by Wire, issued for your agent, not expired, tied to this sign-in by thenonce, and naming a per-agent id. If any check fails it throws and nobody is signed in.- Make a new session for the signed-in person. Do not promote the session they arrived with.
scope controls what you ask for on this sign-in. openid is always included. Add email and profile only if your manifest’s identity lists them: Wire grants no more than the manifest declares. Add offline_access for a refresh token.
The request never names a container or a resource. Wire refuses one from an agent’s own app.
Preview
Section titled “Preview”To run against Wire’s preview environment, pass baseUrl: 'https://preview.app.usewire.io' to WireSignIn.
Call your tools as the person
Section titled “Call your tools as the person”Your agent has one endpoint, on its own hostname. Besides /mcp, which AI clients use, it serves two REST paths for your server.
| Request | What it does |
|---|---|
GET /tools | Lists the tools this sign-in may call |
POST /tools/{name} | Calls one, with the tool’s input as the JSON body |
Nothing else is served there. Your server never needs a container id: Wire finds the person’s container from the access token.
import { WireAgentEndpoint, WireEndpointError } from '@usewire/sdk/agent';
const endpoint = new WireAgentEndpoint({ agentId: 'someday' });
const tools = await endpoint.listTools(accessToken);// [{ name: 'find_places', description, inputSchema, annotations }, ...]
try { const { data } = await endpoint.callTool(accessToken, 'find_places', { q: 'ramen' });} catch (err) { if (err instanceof WireEndpointError && err.code === 'UNAUTHORIZED') { // the access token is no longer good: refresh it, then try again } throw err;}curl https://someday.agent.usewire.io/tools \ -H "Authorization: Bearer $ACCESS_TOKEN"
curl -X POST https://someday.agent.usewire.io/tools/find_places \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'content-type: application/json' \ -d '{ "q": "ramen" }'What comes back depends on your manifest’s level:
| Level | GET /tools | POST /tools/{name} |
|---|---|---|
none | An empty list | Refused |
read | Your tools that do not change a record | Those tools run. A tool that writes is refused, with the reason. |
write | All of your tools | All of them run |
A refused call throws WireEndpointError with code: 'TOOL_REFUSED' and the tool’s own message. A tool call is billed like the same call made by an AI client.
If your agent uses a custom hostname, pass it: new WireAgentEndpoint({ agentId: 'someday', endpoint: 'https://mcp.someday.example' }). Set it from your own configuration, never from a request, because the person’s access token is sent there.
Make these calls from your server. Do not send an access token to a browser to call the endpoint from there.
Refresh tokens
Section titled “Refresh tokens”A refresh token works once. When Wire answers a refresh with new tokens, the one you sent is spent, and the answer carries a new one to store in its place.
Sending a spent refresh token later signs the person out of your agent: Wire treats it as stolen and ends every token your agent holds for them. Three rules keep that from happening by accident.
- Refresh one at a time per person. Take a lock around reading the stored token, refreshing, and writing the new one. Two refreshes at once with the same token is a spent token sent twice.
- Store the new tokens first. That includes when the call throws: if the error has
tokens, Wire had already answered and the old token is spent. - Retry only what is safe to retry.
import { WireSignInError } from '@usewire/sdk/agent';
await withLock(`wire-refresh:${user.id}`, async () => { const stored = await loadTokens(user.id); try { const next = await wire.refresh(stored.refreshToken); await saveTokens(user.id, next); } catch (err) { if (!(err instanceof WireSignInError)) throw err; if (err.tokens) return saveTokens(user.id, err.tokens); // Wire had answered: store these if (err.code === 'INVALID_GRANT') return signOut(user.id); // they sign in again throw err; // UNAVAILABLE or NETWORK_ERROR: see the table }});| The call ended with | Is your refresh token spent? | What to do |
|---|---|---|
| New tokens | Yes | Store the new ones. |
An error with tokens | Yes | Store err.tokens. Do not retry. |
UNAVAILABLE | Almost always no | Retry later with the same token. Wire decides everything before it replaces a token. The exception is a gateway error on the way back after Wire had answered: the retry then answers INVALID_GRANT, and the person signs in again. |
UNEXPECTED_RESPONSE or OAUTH_ERROR without tokens | Unknown | Try once more, not in a loop. If that answers INVALID_GRANT, the person signs in again. |
NETWORK_ERROR | Unknown | Retry once, right away, with the same token. If that answers INVALID_GRANT, the person signs in again. |
INVALID_GRANT | It was already | The person signs in again. |
After a refresh, identity may be null: Wire can leave the ID token out of a refresh answer. The person is who they were, so keep the identity you stored at sign-in.
INVALID_GRANT also means the person disconnected your agent in Wire. Treat it as signed out, not as an error to retry.
Demo accounts
Section titled “Demo accounts”A demo account is a login you create for someone who has to try your app and has no Wire account, such as an app store reviewer. Create one under Demo accounts on your agent’s page. Wire shows its email and password once, so copy both then.
When your agent has a demo account, Wire’s sign-in screen for your agent adds a line: “Have a demo account? Sign in with it.” The reviewer enters the email and password there and carries on through the same screens as anyone else, so what they review is your real sign-in.
| What | For a demo account |
|---|---|
| How many | 3 per agent |
| Where the password works | Only while signing in to your agent |
| How long a sign-in lasts | One day. After that the reviewer signs in again with the same password. |
| What it can keep | Up to 3 containers |
| Who pays | Your organization pays for what it uses with your agent |
Wrong passwords are limited by where they come from, never by account, so nobody can lock your reviewer out by guessing.
Delete a demo account on the same page when the review is over. It can no longer sign in from that moment, and what it saved is removed, usually within a day.
Move from in-browser connect
Section titled “Move from in-browser connect”If your app calls connectInBrowser() today, the move has one rule: the manifest’s access block and your server-side sign-in ship together. Once a manifest with access is registered, your agent’s sign-in needs its client secret, and connectInBrowser(), which finishes in the browser without one, stops working for it. It throws WireSdkError with code: 'ACCESS_AGENT_NEEDS_SERVER'.
Do it in this order:
- Build the server side first, and register nothing new. Add the start and callback routes, token storage keyed by
agentUserId, and your calls toWireAgentEndpoint. Deploy it without linking to it. - Turn on the endpoint and create the client secret on your agent’s page, and add your server’s callback to the redirect addresses.
- In one release, register the manifest with
accessand change your app’s connect button fromconnectInBrowser()to a link to your start route.
People who connected before keep their container and everything in it. The first time each of them signs in, Wire shows what your manifest asks for and they approve it once, on the container they already use.
What changes in your code:
Before, with connectInBrowser() | After, with Sign in with Wire |
|---|---|
The browser finishes the connect and holds a Connection: an API key and a container’s URL. | Your server finishes the sign-in and holds an access token. The browser holds your own session and nothing of Wire’s. |
| You call the container’s URL with the API key. | You call your agent’s endpoint, GET /tools and POST /tools/{name}, with the access token. |
connection.agentUserId identifies the person. | tokens.identity.agentUserId does. It is the same id for the same person, so your existing user records match. |
| Every tool of yours, plus the base tools your manifest lists. | Your tools, held to access.level. |
Errors
Section titled “Errors”Both clients throw typed errors with a code and a retryable flag.
| Error | code | Meaning |
|---|---|---|
WireSignInError | INVALID_GRANT | The code, verifier, or refresh token was refused. The person signs in again. |
INVALID_CLIENT | The client secret was refused, or the agent has none. | |
INVALID_ID_TOKEN | The ID token did not verify. Nobody is signed in. | |
UNAVAILABLE, NETWORK_ERROR | Wire could not answer, or the request did not arrive. retryable is true unless the error carries tokens. | |
WireEndpointError | UNAUTHORIZED | The access token is no longer good. Refresh it. |
TOOL_REFUSED | The tool said no: not allowed at your level, unknown, or bad input. | |
INSUFFICIENT_CREDITS | The call could not be paid for. | |
UNAVAILABLE, NETWORK_ERROR | Try again. retryAfterSec is set when Wire said how long to wait. |
Related
Section titled “Related”- Connect flows: the device and in-browser flows, for agents that do not declare
access. - Installs and webhooks: the same
agentUserId, read from your server, and events when an install changes. - Custom tools: the tools your app calls through the endpoint.