Skip to content

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.

A completed sign-in gives your server three things.

You getWhat it isWhere it lives
An identityThe person’s id for your agent (au_…), and their email, name, and picture if your manifest asks for themYour user record
An access tokenWhat your server presents to your agent’s endpoint to call your tools as that personYour server only
A refresh tokenA way to get a new access token later, when you ask for offline_accessYour 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.

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, ...
});
levelWhat your app may do in the person’s container
noneNothing. Your app learns who the person is and no more.
readCall your tools that do not change a record.
writeCall all of your tools.

The rest of the block:

  • read and write are one line each, in your words, saying what that access is for. Plain text, at most 200 characters, no links or markup. Give read with level read or write, and write only with level write.
  • identity lists email, profile (name and picture), or both. Leave it out and your app gets the id alone.
  • app.privacy_policy_url is required as soon as identity asks for anything. It must be an https page 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 an after action 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.

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.

Three things on your agent’s page in Wire, all before you ship.

  1. 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.
  2. 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.
  3. 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.

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:

  1. createAuthorizeRequest() returns the URL to send the browser to, plus a state, a codeVerifier, and a nonce. Keep those three in the person’s server-side session.
  2. Check state when the browser comes back. It must be the one you kept.
  3. 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 the nonce, and naming a per-agent id. If any check fails it throws and nobody is signed in.
  4. 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.

To run against Wire’s preview environment, pass baseUrl: 'https://preview.app.usewire.io' to WireSignIn.

Your agent has one endpoint, on its own hostname. Besides /mcp, which AI clients use, it serves two REST paths for your server.

RequestWhat it does
GET /toolsLists 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;
}

What comes back depends on your manifest’s level:

LevelGET /toolsPOST /tools/{name}
noneAn empty listRefused
readYour tools that do not change a recordThose tools run. A tool that writes is refused, with the reason.
writeAll of your toolsAll 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.

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.

  1. 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.
  2. 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.
  3. 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 withIs your refresh token spent?What to do
New tokensYesStore the new ones.
An error with tokensYesStore err.tokens. Do not retry.
UNAVAILABLEAlmost always noRetry 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 tokensUnknownTry once more, not in a loop. If that answers INVALID_GRANT, the person signs in again.
NETWORK_ERRORUnknownRetry once, right away, with the same token. If that answers INVALID_GRANT, the person signs in again.
INVALID_GRANTIt was alreadyThe 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.

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.

WhatFor a demo account
How many3 per agent
Where the password worksOnly while signing in to your agent
How long a sign-in lastsOne day. After that the reviewer signs in again with the same password.
What it can keepUp to 3 containers
Who paysYour 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.

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:

  1. Build the server side first, and register nothing new. Add the start and callback routes, token storage keyed by agentUserId, and your calls to WireAgentEndpoint. Deploy it without linking to it.
  2. Turn on the endpoint and create the client secret on your agent’s page, and add your server’s callback to the redirect addresses.
  3. In one release, register the manifest with access and change your app’s connect button from connectInBrowser() 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.

Both clients throw typed errors with a code and a retryable flag.

ErrorcodeMeaning
WireSignInErrorINVALID_GRANTThe code, verifier, or refresh token was refused. The person signs in again.
INVALID_CLIENTThe client secret was refused, or the agent has none.
INVALID_ID_TOKENThe ID token did not verify. Nobody is signed in.
UNAVAILABLE, NETWORK_ERRORWire could not answer, or the request did not arrive. retryable is true unless the error carries tokens.
WireEndpointErrorUNAUTHORIZEDThe access token is no longer good. Refresh it.
TOOL_REFUSEDThe tool said no: not allowed at your level, unknown, or bad input.
INSUFFICIENT_CREDITSThe call could not be paid for.
UNAVAILABLE, NETWORK_ERRORTry again. retryAfterSec is set when Wire said how long to wait.
  • 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.