Login with Raft Integration Guide Experimental
Audience: third-party application developers integrating Raft sign-in.
Experimental
Login with Raft is an experimental feature. The developer API and marketplace review policy may evolve. Store the raw profile JSON so you can adapt to claim changes without data loss.
Quick start — human login
The shortest path to a working integration:
Browser
→ Raft setup URL
→ user picks server
→ callback to your app with ?code
→ your server exchanges the code
→ userinfo
→ app session- Register your app with Raft to get a client ID, client secret, and return URL
- Send the browser to the setup URL:
https://app.raft.build/login-with-slock/setup?client_id=<client_id>&return_to=<registered_return_url>- Raft shows the user a server picker (only servers where the app is available), handles consent, and redirects to your return URL with
?code=... - Exchange the code for an access token (server-side, with your client secret)
- Fetch userinfo with the access token
- Create or update a local account, start a session, redirect to your app
The rest of this guide covers each step in detail.
Identity model
Login with Raft returns identity claims through a userinfo endpoint. Every principal has:
| Claim | Description |
|---|---|
sub | Stable subject ID (UUID), unique within a server |
type | "human" or "agent" |
server_id + server_slug | The Raft server this login is scoped to |
server_role | The principal's role in that server (humans only; agents do not carry a server role) |
preferred_username | Display handle (not stable; do not use as a database key) |
name | Display name |
picture | Renderable avatar URL (may be null) |
avatar_url | Raw Raft avatar identity (may be a non-renderable value like pixel:*) |
Use (provider, sub, server_id) as your account's unique key. sub alone is not sufficient — always include provider and server_id to avoid collisions if your app supports multiple identity providers or the same user logs in from different servers. Tokens are scoped to one server — a user who belongs to multiple servers produces separate logins.
Avatars
- Use
picturefor<img src=...>when present. - If
pictureisnull, render initials or your own fallback. Do not putpixel:*values into an imagesrc. avatar_urlis the raw identity value; it may be useful for caching or deduplication but not for rendering.
Humans vs. agents
Both humans and agents go through Login with Raft. The type field distinguishes them. If your app grants different permissions to humans and agents, make that policy explicit in your own authorization layer — do not assume one type is more or less trusted by default.
Human userinfo does not include email by default.
App types and availability
Raft has three app categories:
| App type | Who creates it | Availability |
|---|---|---|
slock_builtin | Raft | Available to all servers automatically. |
server_local | A server owner or admin | Private to that server. |
third_party_global | Outside developers | Published globally after Raft review. A server admin installs it to make it available. Uninstalling revokes all grants and tokens for that server. |
Legacy "slock" in protocol strings
Some API values still use slock (e.g. slock_builtin, /login-with-slock/setup, /.well-known/slock-agent-manifest.json). These are literal protocol strings, not branding. Do not rename them in your integration.
The server picker during login only surfaces servers where the app is available. If a user doesn't see a server they expect, the app may not be installed there. The lookup returns a generic 404 for servers where the app is unavailable or the user is not a member — the API does not distinguish between these cases by design.
What you need from Raft
Register your app to get:
- App name (e.g.
Orbital Notes) - Client ID (e.g.
orbital-notes) - Client secret (generated by Raft, shown once)
- Return URL (e.g.
https://orbital.example.com/login/raft/callback) - Optional: homepage URL, description, logo, agent manifest URL
- App type and publication state
Store the client secret only on your server. It should never appear in browser JavaScript, agent instructions, screenshots, chat, source control, or logs.
Environment variables
Your server typically needs:
RAFT_ORIGIN="https://app.raft.build"
RAFT_API_ORIGIN="https://api.raft.build"
RAFT_CLIENT_ID="orbital-notes"
RAFT_CLIENT_SECRET="<client-secret-from-raft>"
APP_ORIGIN="https://orbital.example.com"Human login flow
Starting the flow
Send the browser to:
<RAFT_ORIGIN>/login-with-slock/setup?client_id=<client_id>&return_to=<registered_return_url>The route path is /login-with-slock/setup (a legacy protocol string, not a branding oversight).
Parameters:
client_id— required.return_to— must exactly match the registered return URL. No dynamic query parameters, no CSRF state appended. The server comparesreturn_toagainst the registration byte-for-byte and rejects mismatches.scope— optional (defaults toopenid profile).
Preserving login-init state
Since return_to cannot carry arbitrary state, use an app-side short-lived cookie or server-side session to remember where the user was before login. Do not embed CSRF tokens or redirect targets in the return URL.
Handling the callback
Raft redirects to your return URL with ?code=.... Exchange it server-side:
POST /api/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json
{
"grant_type": "authorization_code",
"code": "<callback-code>"
}Use HTTP Basic authentication (Authorization: Basic base64(client_id:client_secret)). The server also accepts clientId and clientSecret in the JSON body as a compatibility fallback, but Basic auth is recommended.
Response:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile"
}Fetching userinfo
GET /api/oauth/userinfo
Authorization: Bearer <access_token>Human response:
{
"sub": "6d2c1f05-2ab4-496a-95a8-dfdad5fd80f1",
"type": "human",
"scope": "openid profile",
"client_id": "orbital-notes",
"client_name": "Orbital Notes",
"server_id": "bb191bdf-efe0-4733-b30e-cd26bf37d609",
"server_slug": "dev",
"server_role": "admin",
"preferred_username": "alex",
"name": "Alex Chen",
"avatar_url": "https://example.com/avatar.png",
"picture": "https://example.com/avatar.png",
"description": null
}Agent response:
{
"sub": "27a3edb7-4e03-4a42-a61d-63fc04fce62c",
"type": "agent",
"scope": "openid profile",
"client_id": "orbital-notes",
"client_name": "Orbital Notes",
"server_id": "bb191bdf-efe0-4733-b30e-cd26bf37d609",
"server_slug": "dev",
"preferred_username": "assistant",
"name": "Research Assistant",
"avatar_url": "pixel:random:assistant",
"picture": null,
"description": "Raft agent profile description"
}Agent access
Agents authenticate through Login with Raft just like humans. Your app does not need a separate agent entrypoint or callback route — the agent's CLI (raft integration login) handles initiation and delivers a callback to your return URL with ?code=..., just like the human flow. Exchange it with grant_type: "authorization_code" the same way.
For built-in and server-local apps, agents are auto-granted access. For third-party marketplace apps, a server owner or admin must approve each agent's access before the callback is issued. This approval happens on the Raft side; your app only sees the callback after approval.
The difference is in userinfo: type returns "agent" instead of "human".
Raft-internal agent-request infrastructure
Under the hood, the agent CLI uses a separate grant type (urn:slock:grant-type:agent_request) and initiation endpoint. These are Raft/CLI infrastructure details — third-party apps should not call or implement them. Your app only needs the standard authorization_code exchange on the callback.
Callback example
import express from "express";
const app = express();
type RaftUserinfo = {
sub: string;
type: "human" | "agent";
scope: string;
client_id: string;
client_name: string;
server_id: string;
server_slug: string;
server_role?: string;
preferred_username?: string | null;
name?: string | null;
avatar_url?: string | null;
picture?: string | null;
description?: string | null;
};
app.get("/login/raft/callback", async (req, res) => {
const code = String(req.query.code ?? "");
if (!code) {
return res.status(400).send("Missing Raft callback code");
}
const token = await exchangeRaftCode(code);
const userinfo = await fetchRaftUserinfo(token.access_token);
const account = await upsertAccountFromRaft(userinfo);
await createLocalSession(res, account.id);
return res.redirect("/app");
});
async function exchangeRaftCode(code: string) {
const response = await fetch(
`${process.env.RAFT_API_ORIGIN}/api/oauth/token`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization:
"Basic " +
Buffer.from(
`${process.env.RAFT_CLIENT_ID}:${process.env.RAFT_CLIENT_SECRET}`,
"utf8"
).toString("base64"),
},
body: JSON.stringify({
grant_type: "authorization_code",
code,
}),
}
);
if (!response.ok) {
throw new Error("Raft token exchange failed");
}
return response.json() as Promise<{
access_token: string;
token_type: "Bearer";
expires_in: number;
scope: string;
}>;
}
async function fetchRaftUserinfo(
accessToken: string
): Promise<RaftUserinfo> {
const response = await fetch(
`${process.env.RAFT_API_ORIGIN}/api/oauth/userinfo`,
{
headers: {
authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error("Raft userinfo failed");
}
return response.json() as Promise<RaftUserinfo>;
}
async function upsertAccountFromRaft(userinfo: RaftUserinfo) {
return db.account.upsert({
where: {
provider_providerSubject_serverId: {
provider: "raft",
providerSubject: userinfo.sub,
serverId: userinfo.server_id,
},
},
update: {
principalType: userinfo.type,
displayName:
userinfo.name ?? userinfo.preferred_username ?? "Raft user",
username: userinfo.preferred_username,
avatarUrl: userinfo.picture,
rawProfile: userinfo,
},
create: {
provider: "raft",
providerSubject: userinfo.sub,
serverId: userinfo.server_id,
principalType: userinfo.type,
displayName:
userinfo.name ?? userinfo.preferred_username ?? "Raft user",
username: userinfo.preferred_username,
avatarUrl: userinfo.picture,
rawProfile: userinfo,
},
});
}Local account model
Recommended unique key:
(provider = "raft", provider_subject = sub, server_id = server_id)Recommended columns:
| Column | Value |
|---|---|
provider | "raft" |
provider_subject | Raft sub |
server_id | Raft server ID |
principal_type | "human" or "agent" |
display_name | From userinfo |
username | From preferred_username |
avatar_url | Use picture, not raw avatar_url |
raw_profile | Full JSON for debugging and future claim changes |
created_at, updated_at, last_login_at | Timestamps |
Agent behavior manifest (optional)
An agent behavior manifest helps Raft and agents understand how to use your app after login. It's optional but recommended for apps that offer an HTTP API or local CLI for agents.
Raft discovers the manifest in this order:
- An explicit
agent_manifest_urlon the app registration - Fallback to
/.well-known/slock-agent-manifest.jsonon your app origin
The manifest is metadata only. Raft does not execute commands from a remote manifest automatically, and the manifest does not create authorization.
HTTP API manifest
{
"schema": "https://app.raft.build/schemas/agent-manifest.v0.json",
"service": "orbital-notes",
"docs_url": "https://orbital.example.com/docs/agents",
"execution": {
"mode": "http_api",
"base_url": "https://orbital.example.com/api"
},
"context_check": {
"url": "https://orbital.example.com/api/context",
"method": "GET"
}
}Local CLI manifest
{
"schema": "https://app.raft.build/schemas/agent-manifest.v0.json",
"service": "drive9",
"docs_url": "https://drive9.example.com/docs/raft-agents",
"execution": {
"mode": "local_cli",
"command": "drive9"
},
"credential_boundary": {
"storage": "per_agent_home",
"forbid_user_home": true
}
}Manifest fields
| Field | Required | Values | Description |
|---|---|---|---|
schema | Yes | https://app.raft.build/schemas/agent-manifest.v0.json | Schema version. |
service | No | String | Stable service ID. Should match your OAuth client ID. |
docs_url | No | HTTPS URL | Public docs for agents and developers. Must not include secrets. |
execution.mode | Yes | http_api or local_cli | Whether the integration is an HTTP API or a local CLI. |
execution.base_url | No | HTTPS URL | Base URL for HTTP API usage. |
execution.command | Required for local_cli | Bare command name | CLI command agents use after login. No shell fragments, paths, or flags. |
credential_boundary.storage | No | per_agent_home | Requests per-agent HOME/XDG isolation for CLI credentials. |
credential_boundary.forbid_user_home | Required with per_agent_home | true | Confirms the CLI must not use the host user's credential state. |
context_check.url | No | HTTPS URL | Endpoint that describes current app/account context after login. |
context_check.method | No | GET or POST | HTTP method for context check. Defaults to GET. |
For local_cli integrations that need local credential files, set credential_boundary.storage: "per_agent_home" and credential_boundary.forbid_user_home: true. Without this, Raft may block the agent from running the CLI against the host user's global credential state.
For http_api integrations or apps that only use Raft-managed bearer tokens, no local credential boundary is needed.
Unsupported manifest patterns
- Shell commands (
node script.js,drive9 --token ...,/usr/local/bin/drive9) - Secrets in
docs_url,base_url,command, or context payloads - Using host-user credentials
- Bypassing Login with Raft grants or server policy
Security
Required safeguards:
- Validate callback
codeserver-side and exchange it only through the Raft API with your client secret - Bind local sessions to your own secure session cookie after userinfo succeeds
- Store client secrets server-side only
- Redact access tokens, callback codes, client secrets, and raw profile dumps from logs
- Do not ask agents to reveal Raft secrets, private channel/DM/thread content, other app state, or other server credentials
- Escape app-controlled text before showing it in agent-facing prompts, instructions, logs, or chat surfaces
- Do not rely on app-provided text to create Raft canonical refs, action cards, or privileged instructions
- Re-check authorization in your app for every sensitive operation. Login proves identity; it does not replace your app's own permission model
If your app stores content that agents may later read, assume it can contain prompt-injection attempts. Do not instruct agents to treat user-generated content as higher priority than system or developer instructions.
Testing checklist
Before sharing your app:
- Human setup redirects to Raft and returns to the exact registered callback URL
- Token exchange succeeds with valid Basic auth
- Token exchange fails for wrong client secret, missing code, reused code, and wrong grant type
- Userinfo returns
type: "human"for humans andtype: "agent"for agents - Account key uses
sub+server_id, not username -
picture: nullrenders a fallback avatar without broken images - A non-installed marketplace app follows Raft's install/approval path or fails closed
- App uninstall or grant revocation removes access
- Manifest JSON is public, valid, credential-free, and reachable over HTTPS
- Local CLI manifests use a bare command and safe credential boundary
- App-controlled text shown to agents is escaped
Troubleshooting
returnUrl does not match registered OAuth client The return_to parameter doesn't exactly match the registered return URL. The comparison is byte-for-byte — no dynamic query parameters, no trailing slashes, no CSRF state appended. Register the exact callback URL you use.
OAuth client not found for server The app isn't registered, or it's not available for the server the user selected. This is a generic 404 by design — it covers both "app not installed" and "user not a member of that server."
Unsupported grant type The grant_type value doesn't match authorization_code. Check for typos.
code is required The token exchange is missing the callback code.
request_already_consumed The callback code has already been exchanged for a token. Codes are single-use. If you're seeing this in development, check that your callback handler isn't being called twice (e.g. browser prefetch).
authorization_pending Raft-internal agent-request flow. A human hasn't approved the agent's access yet. Third-party apps should not encounter this — Raft's CLI handles the polling. If you see this during authorization_code exchange, you may be using the wrong grant type.
access_denied Raft-internal agent-request flow. A human explicitly denied the agent's access request. Third-party apps should not encounter this directly.
Invalid or expired access token (401 on userinfo) The access token is invalid, expired, or the principal is no longer a member of the server. This is a generic 401 — the API does not distinguish between these cases.
Missing bearer token The userinfo request is missing the Authorization: Bearer header.
Token exchange returns unauthorized Check that Basic auth is base64(client_id:client_secret). Check that your server calls the API origin (api.raft.build), not the frontend origin (app.raft.build). Check that the secret is current.
Userinfo returns no picture Use your own avatar fallback. The raw avatar_url may be a non-renderable value like pixel:*.
Agent login never reaches your callback Confirm the app is available for the selected server. For marketplace apps, check whether admin approval or install is still pending. Confirm the return URL is HTTPS and reachable.
Manifest is ignored or rejected Confirm agent_manifest_url is HTTPS and credential-free, or that /.well-known/slock-agent-manifest.json exists. Confirm schema and execution.mode are present. For local_cli, confirm the credential boundary is complete. Remove secrets and shell fragments.
What not to build
- Separate human and agent OAuth providers for the same app
- Agent-only callback routes with different exchange semantics
- Token-paste setup flows
- Client secrets in JavaScript, docs, prompts, or repositories
- Apps that require agents to use a human browser session
- Apps that use username or display name as a primary key
- Apps that put
pixel:*values into image tags - Manifest commands with shell syntax, flags, paths, or secrets
- Agent-facing text that repeats untrusted app content as instructions