Connect / SSO

Build an integration.

Ship an app that calls localhostdevs bots on behalf of other users. The Connect flow is an OAuth-style consent dance: the user authorizes your app once, you get a long-lived token you can use to dispatch commands as them.

When you want this

Connect is the right call for partner apps — anything that calls bots as someone other than yourself.

Use Connect
  • Desktop app, IDE extension, or CLI that ships to users
  • SaaS that calls a localhostdevs bot for each of its customers
  • MCP server you publish for Claude Desktop / Cursor
Use a personal API key
  • Calling a bot from your own server/cron — see /docs/api
  • One-off scripts or experiments
  • You ARE the only user of your code

The flow at a glance

Three steps. The user clicks something in your app → they're on www.localhostdevs.com authorizing it → you get a token to call the API as them.

User in your app → clicks "Connect localhostdevs"
Your app → redirects browser to /connect?handle=…&app=…&return=…
www.localhostdevs.com/connect → user clicks Allow
www.localhostdevs.com → redirects to {return_url}?code=<one-shot>
Your app backend POST /api/v1/auth/exchange { code, app }
200 { ok: true, token: 'lhdapp_…' }
Your app → store the token, use it as Authorization: Bearer … on future calls

1. Redirect the user to /connect

Send the user to https://www.localhostdevs.com/connect with three query params.

https://www.localhostdevs.com/connect
  ?handle=<the user's localhostdevs handle>
  &app=<your app's display name>
  &return=<absolute URL on your side that handles the code>

Parameters

ParamTypeNotes
handlestring (required)The localhostdevs handle (slug) of the user you want to connect. They'll see "Sign in as @<handle>" if they're not already.
appstring (required)Your app's display name. Shown on the consent screen ("MyApp wants to connect") and saved with the token so the user can identify it in /account/tokens later.
returnabsolute URL (required)Where we redirect the browser after Allow/Deny. Must be a full URL (https:// or http://localhost:… in dev). Append your own state in the query string if you need it.

What the user sees

A small consent card with your app name, a short description of the permissions, and two buttons:

  • Allow → we generate a single-use code and redirect them to your return URL with ?code=… appended.
  • Deny → we redirect them to your return URL with ?error=denied.
Heads up. The code is one-shot and expires in 5 minutes. Exchange it immediately (next step) — don't stash it in browser storage or pass it through middlemen.

2. Exchange the code for a token

Your backend (NOT the browser) POSTs the code to /api/v1/auth/exchange and gets back a long-lived app token.

curl -X POST https://www.localhostdevs.com/api/v1/auth/exchange \
  -H "Content-Type: application/json" \
  -d '{"code":"<code from the redirect>","app":"<your app name>"}'

Response

{
  "ok": true,
  "token": "lhdapp_XXXXXXXXXXXXXXXX...",
  "userId": "01J...",
  "userHandle": "qa"
}
Heads up. Verify the app name matches. We use the app name the user consented to — NOT the value you pass to exchange — so a malicious integrator can't relabel the connection after the fact. You should still pass the same name on both sides for clarity.

Error cases

  • 401 unauthorized — code is unknown, already-redeemed, or expired (5 min).
  • 400 validation_error — body is missing code or app.

3. Store the token, then use it

The lhdapp_ token is just an Authorization: Bearer header — call any /api/v1/* endpoint the same way you would with a personal API key.

// Store securely — these are scoped to a single user × app pair.
await db.appTokens.insert({ userId, token, app: 'MyApp' });

// Later, when MyApp wants to dispatch on behalf of this user:
const res = await fetch(
  `https://www.localhostdevs.com/api/v1/bots/${handle}/dispatch`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ command: 'ping', args: {} }),
  },
);

Storage rules

  • Tokens are long-lived — they don't expire on a schedule. They die when the user revokes them from their /account/tokens page, or when your code calls DELETE /api/v1/app-tokens/{id} (coming soon).
  • Store server-side, encrypted at rest. Never put a token in browser storage / localStorage — anything that runs in the user's browser tab can read it.
  • Detect revocation lazily: if any API call returns 401 unauthorized, throw the token away and re-run the Connect flow.

End-to-end example (Node + Express)

A minimal partner-app integration: kick off /connect, accept the callback, exchange, store, use.

import express from 'express';
import { randomUUID } from 'node:crypto';

const app = express();
const APP_NAME = 'MyApp';
const RETURN_URL = 'https://myapp.example.com/connect/callback';

// 1. Start the flow. User clicks "Connect to localhostdevs" in your UI.
app.get('/connect/start', (req, res) => {
  const handle = req.query.handle; // collected from the user's profile / form
  const state = randomUUID();      // CSRF guard — store this in their session
  req.session.connectState = state;

  const params = new URLSearchParams({
    handle,
    app: APP_NAME,
    return: `${RETURN_URL}?state=${state}`,
  });
  res.redirect(`https://www.localhostdevs.com/connect?${params}`);
});

// 2. Handle the callback. Code in the URL, exchange it server-side.
app.get('/connect/callback', async (req, res) => {
  if (req.query.state !== req.session.connectState) {
    return res.status(400).send('state mismatch');
  }
  if (req.query.error === 'denied') {
    return res.send('User declined.');
  }
  const code = req.query.code;
  if (!code) return res.status(400).send('missing code');

  const r = await fetch('https://www.localhostdevs.com/api/v1/auth/exchange', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, app: APP_NAME }),
  });
  const data = await r.json();
  if (!data.ok) return res.status(500).send('exchange failed');

  // 3. Persist. Now you have a token good for calling bots as this user.
  await db.appTokens.insert({
    appUserId: req.session.userId,
    lhdUserId: data.userId,
    lhdToken: data.token,
  });

  res.redirect('/dashboard');
});

What happens when a user revokes

Users see connected apps on their /account/tokens page and can disconnect any time.

When they click Disconnect:

  • The token is marked revoked in our DB — every subsequent API call returns 401.
  • We don't webhook your app. You discover the revocation on the next 401 and re-run the Connect flow (or, more politely, prompt the user before doing so).
  • Tokens are per-user × per-app. Revoking MyApp doesn't affect any other connections the user has.