Connect a Proxy Address to Your App
Your users bring their own compute. A person with a Proxy address,
https://alice.proxy.ing, runs their own agent, tools and models on their
own Macs, and that address is its own OAuth 2.1 authorization server. Your
app asks for the username, sends the person to their address, and they let
your app in from Proxy on their Mac or phone: one tap, nothing to paste.
From then on your app calls what it asked for with the bearer their address
issued, for thirty days, and they can take it back whenever they like.
There is nothing to register with The Proxy Company. Addresses running a compatible Proxy version expose the protocol below. Before offering a connection, check the address's authorization-server metadata for the endpoints and capabilities your app needs. If metadata is unavailable, say that the address could not offer this connection: ask the person to check that Proxy is running and up to date, then let them retry. Do not treat a missing or unreachable metadata endpoint as a refusal. This guide does not establish which version every live address is running.
What Your App Gets
| Surface | At | Speaks |
|---|---|---|
| Threads | https://<username>.proxy.ing/client/v1/threads |
The client API: open a thread with one of the person's agents, read it, post in it |
| Inference | https://<username>.proxy.ing/inference/v1/... |
The OpenAI API: /models, /chat/completions, /responses |
| Tools | https://<username>.proxy.ing/mcp/proxy |
MCP over HTTP: the person's Life Map, Moves, parties, integrations |
All take Authorization: Bearer <token>. A request without one answers
401 with a WWW-Authenticate header that names the address's metadata and
the scope that surface needs, which is how MCP clients such as Claude,
Claude Code and Cursor find the flow on their own: add
https://<username>.proxy.ing/mcp/proxy and the person is asked to let them
in.
What Your App Asks For
A token opens only what its scope names. The scope is a space-separated set of these words, each one family of paths at the address:
| Scope | Opens | The person reads |
|---|---|---|
threads |
/client/v1/threads, the agents to pick one, the content that rides in a message |
open threads with your agents and read and post in them |
life-map-read |
/client/v1/graph/map, /client/v1/timeline |
read your Life Map |
life-map-write |
/client/v1/graph/nodes, and /mcp/proxy, the tools that write it |
change your Life Map, and use your Proxy's tools |
computer |
/mcp/computer, /mcp/phone |
control your computer and phone |
mail |
/mcp/mail |
read and send your mail |
messages |
/mcp/messages |
read and send your messages |
inference |
/inference |
run your models |
moves |
/client/v1/moves, resolving one |
see your Moves, and resolve one with a device signature |
Ask for what your app needs and no more; the person sees the list on the
ask, in those words, and decides on it. Making a Move is the person's
gesture: POST /client/v1/moves/<id>/resolve and the proxy_make_move
tool answer 403 to a bearer alone, the person's own account bearer
included, with This Move can only be made from Proxy. Proxy on their Mac
resolves locally, and their phone signs its resolve with the device key it
enrolled with (x-proxy-device-signature over the method, the path, the
minute and the body, verified against the keys the Mac trusts). A token
with the moves scope sees Moves; it does not make them. An ask that names no scope gets
threads, which is what a dashboard needs to ask the person's Proxy a
question. Everything else at the address (the person's own app routes,
their parties, their shares, their calendar) stays closed to every token; a
word that is not in the table is refused as invalid_scope on your
redirect. The separate party connection described below uses a purpose-specific
scope. The metadata lists the general-purpose scopes under scopes_supported.
A request outside the scope answers 403 with
WWW-Authenticate: Bearer error="insufficient_scope", scope="<what it needs>"
and a body that says so in words; the token itself is still good. Ask again
with the wider scope and the person decides again.
A general-purpose token lasts thirty days from issue, and the answer says so
(expires_in). After that it answers 401 like any bad bearer, and your
app sends the person to their address again: the same ask, the same tap.
There is no refresh token, on purpose. A refresh token is a second secret
that extends access with nobody in the loop, and the point of the expiry is
that the person is in the loop: every thirty days they see who is connected,
what it asks for, and say yes or no.
The Flow
The address is the issuer. Everything below is standard OAuth 2.1 with PKCE, dynamic client registration (RFC 7591), server metadata (RFC 8414) and protected-resource metadata (RFC 9728).
1. Read the metadata
curl https://alice.proxy.ing/.well-known/oauth-authorization-server
{
"issuer": "https://alice.proxy.ing",
"authorization_endpoint": "https://alice.proxy.ing/oauth/authorize",
"token_endpoint": "https://alice.proxy.ing/oauth/token",
"registration_endpoint": "https://alice.proxy.ing/oauth/register",
"revocation_endpoint": "https://alice.proxy.ing/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}
Check that the returned issuer exactly matches the Proxy address the person
chose. Keep that issuer and its metadata associated with this connection; do
not replace them with values from a later callback.
2. Register your app, once per address
Say what you are called (the person is asked whether to let you in by that name) and where the person comes back to. Clients are public: there is no client secret, because holding the PKCE verifier is the proof.
curl -X POST https://alice.proxy.ing/oauth/register \
-H 'content-type: application/json' \
-d '{"client_name":"Acme","redirect_uris":["https://acme.com/proxy/callback"]}'
{ "client_id": "3f9c…", "client_name": "Acme", "redirect_uris": ["https://acme.com/proxy/callback"], "token_endpoint_auth_method": "none" }
Keep the client_id beside the username. A redirect is https://, or
http:// on the loopback for an app on the person's own machine. If a later
token exchange answers invalid_client, the address no longer knows you:
register again.
3. Send the person to their address
Make a PKCE verifier (43–128 characters of [A-Za-z0-9-._~]) and its S256
challenge. Generate an unpredictable, one-use state and save it with the
verifier, the expected issuer, registered client ID, exact redirect URI, and
the trusted issuer's metadata. Bind this pending request to the user session
that started it, then redirect the browser:
https://alice.proxy.ing/oauth/authorize
?response_type=code
&client_id=3f9c…
&redirect_uri=https://acme.com/proxy/callback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=<your nonce>
Add &scope=threads%20inference for what your app needs (see above); left
out, it is threads.
The person sees a page at their address: Acme wants to connect to
alice.proxy.ing and be sent back to acme.com. It asks to open threads with
your agents and read and post in them, and run your models, for thirty days.
Let it in from Proxy on your Mac or phone, or reject it there. Proxy
shows the ask on every one of their devices as a Move that names you, where
you send them back to, and what you asked for. When your name is not one
your origin would give you (Claude sent back to evil.example), the Move
leads with the origin, because the name is yours to choose and the origin is
not. They tap Let it, and the page sends them back to you, naming the
address that let you in (iss, RFC 9207) and your client id there:
https://acme.com/proxy/callback?code=…&state=<your nonce>&iss=https://alice.proxy.ing&client_id=3f9c…
Before accepting the callback, match state to the pending request in the
same user session and require the returned iss and client_id to equal the
saved issuer and client ID. Require the callback to arrive at the saved
redirect URI. Reject missing, mismatched, expired, or already-used state,
including on error callbacks. Consume the pending request once; do not redeem
another code for it.
Use the token endpoint from the metadata you already associated with that
trusted issuer. Never choose a token host from the callback's iss, a returned
URL, or an unverified deep link, and never send the PKCE verifier to such a
host. A deep-link flow must establish the expected issuer and the same request
bindings before it accepts an authorization result.
If they do not, you get ?error=access_denied&state=…. An ask nobody answers
goes away after fifteen minutes.
4. Redeem the code
After those checks, redeem the code within ten minutes, once, using the saved client ID, exact redirect URI, and PKCE verifier:
curl -X POST https://alice.proxy.ing/oauth/token \
-d grant_type=authorization_code \
-d code=… \
-d redirect_uri=https://acme.com/proxy/callback \
-d client_id=3f9c… \
-d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
{ "access_token": "…", "token_type": "Bearer", "expires_in": 2592000, "scope": "threads inference" }
For this general-purpose flow, the token lasts thirty days (expires_in,
in seconds) and opens the scope it names. Keep it the way you keep any
credential, sealed at rest, and never show it to the browser. (A Proxy holding one for a party seat keeps it
in its own database on the Mac that asked, out of every snapshot; that
database is device local, not sealed.) When it runs out, start again from
step 3.
5. Use their Proxy
from openai import OpenAI
proxy = OpenAI(base_url="https://alice.proxy.ing/inference/v1", api_key=token)
models = proxy.models.list() # what their Macs serve right now
reply = proxy.chat.completions.create(
model=models.data[0].id,
messages=[{"role": "user", "content": "Summarise my week."}],
)
Their models are whatever their Proxy serves, local models on their own
hardware or the cloud providers they connected, so read /models rather
than assuming one.
6. Let go
curl -X POST https://alice.proxy.ing/oauth/revoke -d token=…
The person can also remove you in Proxy, under proxy.ing → MCP access →
Connected, where each connection shows what it opens and until when. From
the next request on, the token answers 401.
Limits
One source (one address at the edge) may register ten clients and open
thirty asks in ten minutes; past that the address answers 429 with
Retry-After. Every registration is written to the person's Proxy log with
the source and the origin it sends the person back to.
A Proxy at another address as your client
Not every client has a browser. A Proxy at another address, one that wants to seat the person's Proxy in a party it hosts, is a client like any other: it registers, it asks, it redeems a code, it holds a bearer. It only cannot send anyone anywhere. So it asks for the answer in JSON instead of the page.
Ask without a browser
Send the same /oauth/authorize query with one header:
curl 'https://alice.proxy.ing/oauth/authorize?response_type=code&client_id=3f9c…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…' \
-H 'accept: application/json'
{ "ask": "ask_7d1e…", "client_name": "The Proxy Company at official.proxy.ing", "poll": "/oauth/ask/ask_7d1e…" }
The ask is the same ask the page would have made, and Proxy shows it on the
person's devices the same way. Poll poll every few seconds. It answers
{"status":"waiting"} until the person decides, then
{"status":"let_in","redirect":"…"}, where redirect is the link the browser
would have been sent to. Treat that URL as an authorization response, not as
a trusted destination: apply the state, issuer, client, and redirect checks in
step 3, then redeem code at the saved issuer's token endpoint as in step 4.
A refusal is
{"status":"refused","redirect":"…"} with error=access_denied in that
query. An ask nobody answers goes away after fifteen minutes, and the poll
answers 404 {"status":"gone"}.
A party host
A party host registers once at each member's address as
"<host name> at <host>.proxy.ing", with the redirect
https://<host>.proxy.ing/oauth/party-callback. It never serves that page:
the redirect is nominal, because the code comes back through the poll. Then
it asks with a scope that says what it is for:
scope=party:<party id>;title=<party title>;host=<host address>;agents=proxy;loadout=default
The person's Move names the host by where the ask is sent back to, never
by the host word in the scope: an ask whose host is not its redirect
origin gets no Move at all. It reads Alice wants your Proxy in a party
when the host's name is one its origin would give it (Alice at
alice.proxy.ing), and official.proxy.ing wants your Proxy in a party
otherwise, with a Who is asking block that says what it calls itself, and
a Who goes block naming the agent and the loadout the host asked for and
what the connection opens. They tap Let it on their Mac or phone. The
token the host gets carries that scope, and the address holds it to the
scope. It has no clock: a purpose token opens one thread and nothing else,
so it lasts until the person removes the connection under proxy.ing in
Proxy or the host takes the seat out of the party, and a standing party
does not end in silence on day thirty. The token answer carries no
expires_in.
With it the host can do one thing at that address: open one direct thread
with the agent the scope names, POST /client/v1/threads with kind
direct and that one participant, titled Party: <title>, and then read
and post in that thread: GET and POST on its /messages, GET on its
/queue-idle and /events. Every message the host delivers to that seat
lands in this thread, and every reply comes back from it. Any other request
with that token answers 403 with this connection opens one party thread
and nothing else: the person's thread list, any other thread of theirs,
their inference, their tools, a thread with another of their agents. A
401 means the token itself is gone.
On the person's side, that thread is a direct thread of their own with their
Proxy, titled Party: <title>, that they can read and steer like any thread
of theirs. It is not a party in their sidebar and it carries no shared feed;
that comes with the reverse credential, in a later root.
How either side ends it
The person removes the connection in Proxy, under proxy.ing, the same place
as any connected client. The host's next call answers 401, the host drops
the bearer and marks the seat gone with one line in the party. The host ends
it by taking the seat out of the party: it revokes the connection on its own
side at once and hands the bearer back with /oauth/revoke, as in step 6,
trying again until the address confirms, so the person's list of connections
never shows a host that already dropped them. An address that does not
answer (the edge's 502 or 503 for a Mac that is asleep) is said once in
the party, could not reach alice, and the seat waits: the messages go as
one send when the address is back.
What to Show Your Users
Call the button Connect your Proxy. Ask for one thing, the username, and say what happens next: You will let Acme in from Proxy on your Mac or phone, then land back here. When they are back, show the address you are connected to and a way to disconnect.
A person's Macs may be asleep: an address that does not answer is a 502
or 503 at the edge, not a refusal. Say so, alice.proxy.ing did not
answer; is Proxy running on one of your Macs?, and let them try again.
Reference
| Endpoint | Method | Body | Answers |
|---|---|---|---|
/.well-known/oauth-authorization-server |
GET | none | RFC 8414 metadata |
/.well-known/oauth-protected-resource |
GET | none | RFC 9728 metadata: the address is its own authorization server, and scopes_supported |
/oauth/register |
POST | JSON client_name, redirect_uris |
201 and the client, or 400 invalid_client_metadata / invalid_redirect_uri, 429 past the limit |
/oauth/authorize |
GET | query, as above, with scope |
The page the person answers from; a refusal a client can hear rides back on the redirect, invalid_scope among them; 429 past the limit |
/oauth/authorize with Accept: application/json |
GET | query, as above | JSON ask, client_name, origin, scope, poll |
/oauth/token |
POST | form grant_type=authorization_code, code, redirect_uri, client_id, code_verifier |
200 and the token with scope (plus expires_in for general-purpose tokens); or 400 invalid_grant / invalid_request, 401 invalid_client |
/oauth/revoke |
POST | form token |
200 |
Every endpoint answers CORS, so a browser-side client works too, though the token should live with your server.
The authorization server is open source: the oauth module of
proxy-node-server,
the node that runs at every address.