JohnnyOne Partner API
Drive JohnnyOne programmatically — no UI. Create and control AI/terminal sessions and planner/development runs, and stream live terminal output, over two authenticated transports.
johnnyone.ethan-353.workers.dev (prod). All traffic is over TLS. For dev/qa use the corresponding -dev/-qa subdomain.Two transports
| Transport | Use it for | Auth |
|---|---|---|
GraphQL https://johnnyone.ethan-353.workers.dev/graphql | Login, session lifecycle CRUD, planner/development lifecycle CRUD, listing nodes & tools. | Authorization: Bearer <accessToken> |
WebSocket wss://johnnyone.ethan-353.workers.dev/api/relay/ws | Live terminal I/O — subscribe to screen updates, send input, resize, kill. | Authorization: Bearer <accessToken> on upgrade, or ?token=<accessToken> |
Your account is a dedicated service account (one tenant + user). JohnnyOne hosts the desktop runtime for you — you never install anything. The server resolves your desktop node from your token; you never supply a node id.
Authentication
Log in once to get a short-lived accessToken (JWT) and a longer-lived refreshToken. Send the access token on every GraphQL request and on the WebSocket upgrade.
curl -s https://johnnyone.ethan-353.workers.dev/graphql \
-H 'content-type: application/json' \
-H 'x-tenant-id: your-tenant' \
-d '{"query":"mutation($i:LoginInput!){login(input:$i){accessToken refreshToken}}",
"variables":{"i":{"email":"you@partner.com","password":"…","tenantId":"your-tenant"}}}'
Use the token as a Bearer header on GraphQL:
Authorization: Bearer <accessToken>
refreshToken(input: RefreshTokenInput!) to get a new pair (returns { accessToken refreshToken }). A WebSocket authenticates once at upgrade — if the token expires mid-connection, reconnect with a fresh token (see Errors & limits).?token= fallback. Browser WebSocket clients can't set headers, so the token may be passed as a query param. Only over TLS, only with a short-lived access token, and don't log or persist the URL.GraphQL API
Endpoint: POST https://johnnyone.ethan-353.workers.dev/graphql. The schema is introspectable — point a GraphQL client at it to discover every type and argument. The partner-usable operations are below.
Terminal / AI sessions
| Operation | Signature |
|---|---|
| Create | createAiSession(input: CreateAiSessionInput!): AiSession! |
| List | listAiSessions(status: String): [AiSession!]! |
| Get | getAiSession(id: ID!): AiSession |
| Rename | updateAiSessionTitle(id: ID!, title: String!): AiSession! |
| Set working dir | updateAiSessionWorkingDirectory(id: ID!, workingDirectory: String!): AiSession! |
| Set provider | updateAiSessionProvider(id: ID!, provider: String!): AiSession! |
| Archive | updateAiSessionArchived(id: ID!): AiSession! |
| Delete | deleteAiSession(id: ID!): Boolean! |
| List messages | listAiMessages(sessionId: ID!, limit: Int, offset: Int): [AiMessage!]! |
CreateAiSessionInput: { title?, provider?, model?, workingDirectory? }. The returned AiSession.id is the sessionId you use on the WebSocket.
listAiMessages returns messages for a session (use with listAiSessions / getAiSession).
curl -s https://johnnyone.ethan-353.workers.dev/graphql \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"query":"mutation($i:CreateAiSessionInput!){createAiSession(input:$i){id title provider workingDirectory}}",
"variables":{"i":{"provider":"claude_code","workingDirectory":"/home/you/project"}}}'
Planner / development runs
| Operation | Signature |
|---|---|
| Create | createAgentPlan(input: CreateAgentPlanInput!): AgentPlanRun! |
| Start | startAgentPlan(id: ID!, phaseId: String, phaseRunMode: String): AgentPlanRun! |
| List | listAgentPlans(status: String, runType: String, onlyExisting: Boolean): [AgentPlan!]! |
| Get (with phases/tasks/events) | getAgentPlan(id: ID!): AgentPlanRun! |
| Rename | updateAgentPlanTitle(id: ID!, title: String!): AgentPlanRun! |
| Amend | updateAgentPlanAmend(id: ID!, brief: String!): AgentPlanRun! |
| Stop | updateAgentPlanStopped(id: ID!): AgentPlanRun! |
| App scope | updateAgentPlanAppScope(id: ID!, appScope: String): AgentPlanRun! |
| Block | updateAgentPlanBlocked(id: ID!, reason: String!): AgentPlanRun! |
| Delete | deleteAgentPlan(id: ID!): Boolean! |
CreateAgentPlanInput: { runType?, title?, workspacePath!, planPath!, workerProvider!, reviewerProvider!, brief?, appScope?, docsScope?, referencePaths? }. getAgentPlan returns an AgentPlanRun with plan, phases, tasks, and events — poll it (or watch agent_plan_run_updated on the WebSocket) to track progress.
Discovery helpers
listDesktopNodes (your online desktop node) · listDetectedCliTools (available providers/CLIs on your host).
Live terminal (WebSocket)
Connect to wss://johnnyone.ethan-353.workers.dev/api/relay/ws with your token. The server validates the token, resolves your desktop node, and routes the socket. Messages are JSON envelopes:
{ "type": "<envelope type>", "sessionId": "<AiSession.id>", "data": <payload> }
Client → server
| type | Payload | Meaning |
|---|---|---|
terminal_visual_subscribe | { sessionId } | Start receiving terminal_screen updates for the session. |
terminal_command | { sessionId, data: "<input>" } | Send keystrokes / input to the terminal. |
terminal_resize | { sessionId, data: { cols, rows } } | Resize the terminal. |
terminal_visual_unsubscribe | { sessionId } | Stop receiving updates. |
terminal_kill | { sessionId } | Terminate the terminal session. |
Server → client
| type | Payload | Meaning |
|---|---|---|
terminal_screen | { sessionId, data: <screen> } | Current terminal screen contents (rendered output). Sent on subscribe and on change. |
terminal_command_ack | { sessionId } | Input was accepted. |
session_deleted / session_updated | { sessionId } | Session lifecycle change. |
agent_plan_run_updated | { data } | A plan run advanced (phase/status change) — handy for live plan progress. |
sessionId is rejected. You never send a node id — the server derives your node from the token.Quickstart (Node.js)
Login → create a session → stream its terminal → send a command. Requires ws (and Node 18+ for fetch).
import WebSocket from 'ws';
const BASE = 'https://johnnyone.ethan-353.workers.dev';
const gql = (q, variables, token) => fetch(BASE + '/graphql', {
method: 'POST',
headers: { 'content-type': 'application/json', ...(token && { authorization: `Bearer ${token}` }) },
body: JSON.stringify({ query: q, variables }),
}).then(r => r.json());
// 1. Login
const { data: { login } } = await gql(
'mutation($i:LoginInput!){login(input:$i){accessToken refreshToken}}',
{ i: { email: 'you@partner.com', password: process.env.PW, tenantId: 'your-tenant' } });
const token = login.accessToken;
// 2. Create a session
const { data: { createAiSession: s } } = await gql(
'mutation($i:CreateAiSessionInput!){createAiSession(input:$i){id}}',
{ i: { provider: 'claude_code', workingDirectory: '/home/you/project' } }, token);
// 3. Open the live terminal
const ws = new WebSocket(`${BASE.replace('https','wss')}/api/relay/ws`, {
headers: { authorization: `Bearer ${token}` }, // or append ?token= in the URL
});
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'terminal_visual_subscribe', sessionId: s.id }));
ws.send(JSON.stringify({ type: 'terminal_command', sessionId: s.id, data: 'echo hello\r' }));
});
ws.on('message', (buf) => {
const msg = JSON.parse(buf.toString());
if (msg.type === 'terminal_screen') console.log(msg.data);
});
A Python equivalent uses httpx/urllib for GraphQL and websockets for the socket — same envelopes.
Errors & limits
- WebSocket upgrade rejected:
401= missing/invalid token;503= no online desktop node for your account. Retry after re-authenticating / once the host is online. - GraphQL errors come back in the standard
errors[]array (e.g.Authentication requiredfor a missing/expired token). - Foreign session: an envelope or query for a session you don't own is rejected with error code
forbidden_session. - Token expiry mid-socket: the socket authenticates once at upgrade. When the access token nears expiry, refresh it and reconnect; re-subscribe after reconnect. Build in reconnect-with-backoff.
Versioning & forward-compat
The endpoint is unversioned and additive. Select only the fields you need; tolerate unknown enum values and extra response fields.
For errors use the real signals: GraphQL errors[].message text, WSS upgrade HTTP status 401/503, and in terminal_command_ack the .error:'forbidden_session' field. (The published contract does not emit extensions.code values such as UNAUTHENTICATED/FORBIDDEN_SCOPE/RATE_LIMITED.)
Changelog
- 2026-06-18 — v1 initial public surface: AI-session lifecycle + AgentPlan GraphQL CRUD; live terminal I/O over WSS relay envelopes; JWT +
?token=auth; server-side node resolution; session ownership gate; minimal Node.js + Python example clients; public docs site on CF Pages athttps://johnnyone-partner-api.pages.dev.
Full policy: see the inlined summary above and the runbook (deployed alongside). All changes additive unless removal sweep with deprecation.
For AI agents
- GraphQL is introspectable — run a standard introspection query against
/graphqlto get the full typed schema; you don't need to hardcode the operations above. - The WebSocket protocol is the only non-introspectable part — the envelope tables in Live terminal are the complete contract.
- Start from the Quickstart — it's the full happy path (login → session → subscribe → command → read screen) in ~25 lines.
- Identity is server-resolved — authenticate with the token only; never send a node id.
JohnnyOne Partner API · integration guide. Operations and envelopes reflect the live GraphQL schema (worker/schema/*.graphql) and relay protocol.