@quo-systems/dock 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +27 -0
  4. package/beings/avatar.ts +84 -0
  5. package/beings/desk.ts +46 -0
  6. package/beings/index.ts +5 -0
  7. package/beings/quo-dock.md +698 -0
  8. package/beings/setup.ts +23 -0
  9. package/beings/side.ts +51 -0
  10. package/beings/user.ts +78 -0
  11. package/cli/client.ts +65 -0
  12. package/cli/daemon.ts +582 -0
  13. package/cli/quo.ts +126 -0
  14. package/dist/beings/avatar.d.ts +32 -0
  15. package/dist/beings/avatar.js +82 -0
  16. package/dist/beings/desk.d.ts +42 -0
  17. package/dist/beings/desk.js +43 -0
  18. package/dist/beings/index.d.ts +3 -0
  19. package/dist/beings/index.js +5 -0
  20. package/dist/beings/setup.d.ts +2 -0
  21. package/dist/beings/setup.js +16 -0
  22. package/dist/beings/side.d.ts +25 -0
  23. package/dist/beings/side.js +37 -0
  24. package/dist/beings/user.d.ts +108 -0
  25. package/dist/beings/user.js +74 -0
  26. package/dist/cli/client.d.ts +14 -0
  27. package/dist/cli/client.js +51 -0
  28. package/dist/cli/daemon.d.ts +57 -0
  29. package/dist/cli/daemon.js +581 -0
  30. package/dist/cli/quo.d.ts +2 -0
  31. package/dist/cli/quo.js +121 -0
  32. package/dist/harbor/browser.d.ts +13 -0
  33. package/dist/harbor/browser.js +50 -0
  34. package/dist/harbor/disk.d.ts +19 -0
  35. package/dist/harbor/disk.js +126 -0
  36. package/dist/harbor/edge/edge.d.ts +26 -0
  37. package/dist/harbor/edge/edge.js +123 -0
  38. package/dist/harbor/edge/exercise.d.ts +7 -0
  39. package/dist/harbor/edge/exercise.js +179 -0
  40. package/dist/harbor/edge/storage.d.ts +20 -0
  41. package/dist/harbor/edge/storage.js +81 -0
  42. package/dist/harbor/edge/worker.d.ts +14 -0
  43. package/dist/harbor/edge/worker.js +47 -0
  44. package/dist/harbor/files.d.ts +14 -0
  45. package/dist/harbor/files.js +83 -0
  46. package/dist/harbor/idb.d.ts +15 -0
  47. package/dist/harbor/idb.js +85 -0
  48. package/dist/human/dom.d.ts +2 -0
  49. package/dist/human/dom.js +15 -0
  50. package/dist/human/html.d.ts +32 -0
  51. package/dist/human/html.js +136 -0
  52. package/dist/human/screen.d.ts +11 -0
  53. package/dist/human/screen.js +66 -0
  54. package/dist/human/tab.d.ts +7 -0
  55. package/dist/human/tab.js +68 -0
  56. package/dist/mcp/agent.d.ts +10 -0
  57. package/dist/mcp/agent.js +78 -0
  58. package/dist/mcp/http.d.ts +27 -0
  59. package/dist/mcp/http.js +96 -0
  60. package/dist/mcp/oauth.d.ts +98 -0
  61. package/dist/mcp/oauth.js +241 -0
  62. package/dist/mcp/pilot.d.ts +6 -0
  63. package/dist/mcp/pilot.js +68 -0
  64. package/dist/mcp/runner.d.ts +45 -0
  65. package/dist/mcp/runner.js +94 -0
  66. package/dist/mcp/server.d.ts +9 -0
  67. package/dist/mcp/server.js +63 -0
  68. package/dist/mcp/web/exchange.d.ts +23 -0
  69. package/dist/mcp/web/exchange.js +136 -0
  70. package/harbor/browser.ts +63 -0
  71. package/harbor/disk.ts +120 -0
  72. package/harbor/edge/edge.ts +147 -0
  73. package/harbor/edge/exercise.ts +187 -0
  74. package/harbor/edge/platform.d.ts +28 -0
  75. package/harbor/edge/storage.ts +82 -0
  76. package/harbor/edge/worker.ts +47 -0
  77. package/harbor/edge/wrangler.toml +21 -0
  78. package/harbor/files.ts +89 -0
  79. package/harbor/idb.ts +90 -0
  80. package/harbor/quo-harbor.md +403 -0
  81. package/human/dom.ts +21 -0
  82. package/human/html.ts +164 -0
  83. package/human/quo-human.md +86 -0
  84. package/human/screen.ts +76 -0
  85. package/human/tab.ts +84 -0
  86. package/mcp/agent.ts +94 -0
  87. package/mcp/http.ts +96 -0
  88. package/mcp/oauth.ts +260 -0
  89. package/mcp/pilot.ts +73 -0
  90. package/mcp/quo-mcp.md +262 -0
  91. package/mcp/runner.ts +131 -0
  92. package/mcp/server.ts +67 -0
  93. package/mcp/web/exchange.ts +146 -0
  94. package/package.json +89 -0
package/mcp/oauth.ts ADDED
@@ -0,0 +1,260 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // OAuth on the mcp route: the credential exchange for a remote MCP client.
3
+ // An authorization server small enough to read, and nothing a client does
4
+ // not need: metadata, dynamic registration, PKCE with S256, the code grant,
5
+ // refresh, opaque tokens. It is a route, not Quo. Its one job is to end with
6
+ // a client identity, which the front desk trades for an invitation and the
7
+ // avatar knocks with; the tokens it keeps map a bearer to that identity and
8
+ // nothing else. No secret of Quo's is ever here.
9
+ //
10
+ // GET /.well-known/oauth-authorization-server who issues, and where
11
+ // GET /.well-known/oauth-protected-resource[/mcp] which issuer guards the MCP endpoint
12
+ // POST /register a client names itself and its redirects
13
+ // GET /authorize starts a request; sent to the web route to finish
14
+ // POST /token code or refresh for an access token
15
+ //
16
+ // The web route finishes a request by calling `complete(request, identity)`
17
+ // after its own login and allow pages; that returns the redirect that
18
+ // carries the code back to the client. Step 8 builds those pages.
19
+ import { randomBytes, createHash } from 'node:crypto';
20
+ import type { IncomingMessage, ServerResponse } from 'node:http';
21
+
22
+ // A token, a code, a client id: every key here is a string the far side chose,
23
+ // and a bare lookup would find `__proto__` and hand back an object with no
24
+ // identity. Own keys only.
25
+ const own = <T>(rec: Record<string, T>, key: string): T | undefined => (Object.hasOwn(rec, key) ? rec[key] : undefined);
26
+
27
+ export const CODE_TTL = 10 * 60 * 1000; // a code, and a pending request, live ten minutes
28
+ export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
29
+ export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
30
+
31
+ export type Client = { client_id: string; client_name: string; redirect_uris: string[] };
32
+ export type Pending = { client_id: string; redirect_uri: string; challenge: string; state: string | null; resource: string | null; exp: number };
33
+ export type Grant = { identity: string; client_id: string; exp: number };
34
+ export type Store = {
35
+ clients: Record<string, Client>;
36
+ pending: Record<string, Pending>;
37
+ codes: Record<string, Pending & { identity: string }>;
38
+ access: Record<string, Grant>;
39
+ refresh: Record<string, Grant>;
40
+ };
41
+ export const emptyStore = (): Store => ({ clients: {}, pending: {}, codes: {}, access: {}, refresh: {} });
42
+
43
+ export type Options = {
44
+ issuer: string; // https://mcp.lab.quo.systems
45
+ resource: string; // https://mcp.lab.quo.systems/mcp
46
+ finish: (request: string) => string; // where the web route takes a pending request
47
+ store: Store;
48
+ persist: (store: Store) => Promise<void>;
49
+ now?: () => number;
50
+ };
51
+
52
+ const token = () => randomBytes(32).toString('base64url');
53
+ const s256 = (v: string) => createHash('sha256').update(v).digest('base64url');
54
+
55
+ export class OAuth {
56
+ readonly o: Options;
57
+ constructor(o: Options) {
58
+ this.o = o;
59
+ }
60
+ get store() {
61
+ return this.o.store;
62
+ }
63
+ now() {
64
+ return this.o.now?.() ?? Date.now();
65
+ }
66
+
67
+ metadata() {
68
+ const i = this.o.issuer;
69
+ return {
70
+ issuer: i,
71
+ authorization_endpoint: `${i}/authorize`,
72
+ token_endpoint: `${i}/token`,
73
+ registration_endpoint: `${i}/register`,
74
+ response_types_supported: ['code'],
75
+ grant_types_supported: ['authorization_code', 'refresh_token'],
76
+ code_challenge_methods_supported: ['S256'],
77
+ token_endpoint_auth_methods_supported: ['none'],
78
+ scopes_supported: ['quo'],
79
+ };
80
+ }
81
+
82
+ protectedResource() {
83
+ return { resource: this.o.resource, authorization_servers: [this.o.issuer], bearer_methods_supported: ['header'] };
84
+ }
85
+
86
+ async register(body: unknown): Promise<Client | { error: string }> {
87
+ const b = body as { client_name?: unknown; redirect_uris?: unknown };
88
+ const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
89
+ if (uris.length === 0) return { error: 'invalid_redirect_uri' };
90
+ const client: Client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
91
+ this.store.clients[client.client_id] = client;
92
+ await this.o.persist(this.store);
93
+ return client;
94
+ }
95
+
96
+ // Start a request. What comes back is where to send the browser: the web
97
+ // route with the request id, or the client's redirect with an error.
98
+ async authorize(q: URLSearchParams): Promise<{ redirect: string } | { error: string }> {
99
+ const client = own(this.store.clients, q.get('client_id') ?? '');
100
+ const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
101
+ if (!client || redirect === null || !client.redirect_uris.includes(redirect)) return { error: 'invalid_client' };
102
+ const back = (error: string) => ({ redirect: withQuery(redirect, { error, state: q.get('state') }) });
103
+ if (q.get('response_type') !== 'code') return back('unsupported_response_type');
104
+ const challenge = q.get('code_challenge');
105
+ if (!challenge || q.get('code_challenge_method') !== 'S256') return back('invalid_request');
106
+ const id = token();
107
+ this.store.pending[id] = { client_id: client.client_id, redirect_uri: redirect, challenge, state: q.get('state'), resource: q.get('resource'), exp: this.now() + CODE_TTL };
108
+ this.sweep();
109
+ await this.o.persist(this.store);
110
+ return { redirect: this.o.finish(id) };
111
+ }
112
+
113
+ // What the web route shows: who is asking. Null if the request is gone.
114
+ pending(id: string): (Pending & { client: Client }) | null {
115
+ const p = own(this.store.pending, id);
116
+ if (!p || p.exp < this.now()) return null;
117
+ const client = own(this.store.clients, p.client_id);
118
+ return client ? { ...p, client } : null;
119
+ }
120
+
121
+ // The web route's last step: the human allowed this client as this identity.
122
+ async complete(id: string, identity: string): Promise<{ redirect: string } | { error: string }> {
123
+ const p = this.pending(id);
124
+ if (!p) return { error: 'expired' };
125
+ delete this.store.pending[id];
126
+ const code = token();
127
+ this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
128
+ await this.o.persist(this.store);
129
+ return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
130
+ }
131
+
132
+ // The human refused, or the request died: the client hears that.
133
+ async deny(id: string): Promise<{ redirect: string } | { error: string }> {
134
+ const p = this.pending(id);
135
+ if (!p) return { error: 'expired' };
136
+ delete this.store.pending[id];
137
+ await this.o.persist(this.store);
138
+ return { redirect: withQuery(p.redirect_uri, { error: 'access_denied', state: p.state }) };
139
+ }
140
+
141
+ async token(body: URLSearchParams): Promise<Record<string, unknown>> {
142
+ const grant = body.get('grant_type');
143
+ if (grant === 'authorization_code') {
144
+ const c = own(this.store.codes, body.get('code') ?? '');
145
+ if (!c || c.exp < this.now()) return { error: 'invalid_grant' };
146
+ delete this.store.codes[body.get('code') ?? '']; // a code is spent the first time it is offered, right or wrong
147
+ const verifier = body.get('code_verifier') ?? '';
148
+ if (s256(verifier) !== c.challenge || (body.get('client_id') ?? c.client_id) !== c.client_id || (body.get('redirect_uri') ?? c.redirect_uri) !== c.redirect_uri) {
149
+ await this.o.persist(this.store);
150
+ return { error: 'invalid_grant' };
151
+ }
152
+ return this.issue(c.identity, c.client_id);
153
+ }
154
+ if (grant === 'refresh_token') {
155
+ const rt = body.get('refresh_token') ?? '';
156
+ const r = own(this.store.refresh, rt);
157
+ if (!r || r.exp < this.now()) return { error: 'invalid_grant' };
158
+ delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
159
+ return this.issue(r.identity, r.client_id);
160
+ }
161
+ return { error: 'unsupported_grant_type' };
162
+ }
163
+
164
+ async issue(identity: string, client_id: string) {
165
+ const access_token = token(),
166
+ refresh_token = token();
167
+ this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
168
+ this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
169
+ this.sweep();
170
+ await this.o.persist(this.store);
171
+ return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
172
+ }
173
+
174
+ // The bearer on an MCP request, to a client identity. Null is 401.
175
+ bearer(req: IncomingMessage): string | null {
176
+ const h = req.headers.authorization ?? '';
177
+ const t = h.startsWith('Bearer ') ? h.slice(7) : '';
178
+ const g = own(this.store.access, t);
179
+ return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
180
+ }
181
+
182
+ // Every grant an identity holds, gone: the route's half of revocation.
183
+ // The other half is the user being removing the occupant.
184
+ async revoke(identity: string): Promise<void> {
185
+ for (const k of ['access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.identity === identity) delete this.store[k][t];
186
+ await this.o.persist(this.store);
187
+ }
188
+
189
+ sweep() {
190
+ const now = this.now();
191
+ for (const k of ['pending', 'codes', 'access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.exp < now) delete this.store[k][t];
192
+ }
193
+
194
+ // The HTTP face. `rest` is the path under the route.
195
+ async handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean> {
196
+ const url = new URL(req.url ?? '/', 'http://localhost');
197
+ const json = (status: number, body: unknown, headers: Record<string, string> = {}) => {
198
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });
199
+ res.end(JSON.stringify(body));
200
+ };
201
+ if (rest === '/.well-known/oauth-authorization-server' && req.method === 'GET') return json(200, this.metadata()), true;
202
+ if ((rest === '/.well-known/oauth-protected-resource' || rest === '/.well-known/oauth-protected-resource/mcp') && req.method === 'GET') return json(200, this.protectedResource()), true;
203
+ if (rest === '/register' && req.method === 'POST') {
204
+ const out = await this.register(await readJson(req));
205
+ return json('error' in out ? 400 : 201, out), true;
206
+ }
207
+ if (rest === '/authorize' && req.method === 'GET') {
208
+ const out = await this.authorize(url.searchParams);
209
+ if ('error' in out) return json(400, out), true;
210
+ res.writeHead(302, { location: out.redirect, 'cache-control': 'no-store' });
211
+ res.end();
212
+ return true;
213
+ }
214
+ if (rest === '/token' && req.method === 'POST') {
215
+ const out = await this.token(await readForm(req));
216
+ return json('error' in out ? 400 : 200, out), true;
217
+ }
218
+ return false;
219
+ }
220
+
221
+ // What an unauthenticated MCP request is told: where to go.
222
+ challenge(res: ServerResponse) {
223
+ res.writeHead(401, { 'www-authenticate': `Bearer resource_metadata="${this.o.issuer}/.well-known/oauth-protected-resource/mcp"`, 'content-type': 'application/json' });
224
+ res.end(JSON.stringify({ error: 'unauthorized' }));
225
+ }
226
+ }
227
+
228
+ function withQuery(base: string, params: Record<string, string | null>): string {
229
+ const u = new URL(base);
230
+ for (const [k, v] of Object.entries(params)) if (v !== null) u.searchParams.set(k, v);
231
+ return u.toString();
232
+ }
233
+
234
+ export async function readBody(req: IncomingMessage, limit = 64 * 1024): Promise<string> {
235
+ let s = '';
236
+ for await (const chunk of req) {
237
+ s += chunk;
238
+ if (s.length > limit) throw new Error('too large');
239
+ }
240
+ return s;
241
+ }
242
+ async function readJson(req: IncomingMessage): Promise<unknown> {
243
+ try {
244
+ return JSON.parse(await readBody(req));
245
+ } catch {
246
+ return {};
247
+ }
248
+ }
249
+ export async function readForm(req: IncomingMessage): Promise<URLSearchParams> {
250
+ const body = await readBody(req);
251
+ if ((req.headers['content-type'] ?? '').includes('application/json')) {
252
+ try {
253
+ const o = JSON.parse(body) as Record<string, unknown>;
254
+ return new URLSearchParams(Object.entries(o).map(([k, v]) => [k, String(v)]));
255
+ } catch {
256
+ return new URLSearchParams();
257
+ }
258
+ }
259
+ return new URLSearchParams(body);
260
+ }
package/mcp/pilot.ts ADDED
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The owner pilot: `quo pilot`. An MCP server over stdio that reaches a
3
+ // ward's owner asks through the daemon's root socket, and exposes four tools
4
+ // and no more, plus the one read. Whoever runs this process is the owner, by
5
+ // the device's own rules: the root of the ward here, or, with `via`, an
6
+ // owner at another ward's door, on a standing the user being here holds
7
+ // there. Every call is logged with what it made. The owner creates and
8
+ // places; the work goes through `quo side`, under a gate.
9
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
11
+ import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
12
+ import { ask } from '../cli/client.ts';
13
+ import type { Serving } from '../beings/side.ts';
14
+
15
+ export const PILOT_TOOLS: Tool[] = [
16
+ {
17
+ name: 'census',
18
+ description: 'the empty ask: the ward pk and every being, with class, public and digest',
19
+ inputSchema: { type: 'object', properties: {} },
20
+ },
21
+ {
22
+ name: 'boot',
23
+ description: 'boot a being by class name under a key; public marks the one public being',
24
+ inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
25
+ },
26
+ {
27
+ name: 'invite',
28
+ description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
29
+ inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
30
+ },
31
+ {
32
+ name: 'knock',
33
+ description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
38
+ id: { type: 'string' },
39
+ invitation: { type: 'object' },
40
+ method: { type: 'string' },
41
+ args: { type: 'object' },
42
+ wanted: { type: 'object', properties: { time: { type: 'number' } } },
43
+ },
44
+ required: ['being', 'id', 'invitation'],
45
+ },
46
+ },
47
+ {
48
+ name: 'remove',
49
+ description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
50
+ inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
51
+ },
52
+ ];
53
+
54
+ export type Log = (line: string) => void;
55
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
56
+
57
+ export async function pilotSide(dir: string, ward: string, transport: Transport, log: Log = () => {}, via?: string): Promise<Serving> {
58
+ const server = new Server({ name: 'quo-pilot', version: '0.0.0' }, { capabilities: { tools: {} } });
59
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: PILOT_TOOLS }));
60
+ server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
61
+ const name = req.params.name;
62
+ const args: Record<string, unknown> = req.params.arguments ?? {};
63
+ if (!PILOT_TOOLS.some((t) => t.name === name)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown ask' }) }], structuredContent: { error: 'unknown ask' }, isError: true };
64
+ const out = await ask(dir, name === 'census' ? undefined : name, args, ward, via);
65
+ const value = 'error' in out ? { error: out.error } : out.result;
66
+ const failed = 'error' in out || (typeof value === 'object' && value !== null && 'error' in value);
67
+ log(`${via === undefined ? ward : `${ward} via ${via}`} ${name} ${JSON.stringify(args)} -> ${JSON.stringify(value)}`);
68
+ const structured = isRecord(value) ? { structuredContent: value } : {};
69
+ return { content: [{ type: 'text', text: JSON.stringify(value) }], ...structured, ...(failed ? { isError: true } : {}) };
70
+ });
71
+ await server.connect(transport);
72
+ return { close: () => server.close() };
73
+ }
package/mcp/quo-mcp.md ADDED
@@ -0,0 +1,262 @@
1
+ # The model side
2
+
3
+ This is the one way a model meets a Quo world. It is one side of the avatar
4
+ described in `packages/dock/beings/quo-dock.md`, which owns the avatar, the front
5
+ desk, the credential exchange, the roles and the shared invariants; this
6
+ document assumes all of that and adds only what a model needs. It is
7
+ model-agnostic on purpose. Claude, GPT, Kimi, a model in LM Studio, a model
8
+ behind OpenRouter: capable or not, local or hosted, speaking MCP or plain
9
+ function calling, every one meets a world in exactly one shape.
10
+
11
+ A remote model, claude.ai or an API behind a vendor, is an occupant and
12
+ nothing else. A local agent, Claude Code, `claude -p`, or any CLI agent on
13
+ the Mac or the droplet, may also be the owner and the developer, and keeps
14
+ the three apart.
15
+
16
+ ## What MCP is, in Quo words
17
+
18
+ MCP is the description and value layer and nothing more. The mapping is total
19
+ and needs no translation code beyond an envelope:
20
+
21
+ | MCP | Quo |
22
+ | ------------------------ | ------------------------------------------------------------ |
23
+ | server | an avatar's side |
24
+ | session | a standing the avatar holds, persistent across sessions |
25
+ | tools/list | the empty ask on that standing: describe for this asker |
26
+ | tool name, inputSchema | `asks[].name`, `asks[].input`, verbatim |
27
+ | tools/call | a named ask with args |
28
+ | result content | the answer object, as JSON |
29
+ | tool error result | an error object she answered, `{ error }`, as JSON |
30
+ | protocol error | silence and the ward's words, see the table below |
31
+ | auth token | an invitation, used once, then keys |
32
+ | resources, prompts | not mapped. Notes in the blueprint may carry hints. |
33
+
34
+ A tool list is a describe. Because a being describes per asker, two models
35
+ connected to the same user being see two different tool lists, and neither
36
+ can call what it cannot see: the gate is one decision for describe and for
37
+ dispatch.
38
+
39
+ The three words for "no object" cross the envelope like this:
40
+
41
+ | the avatar heard | the client gets |
42
+ | ---------------- | ------------------------------------------------------------------- |
43
+ | an object | a tool result with that JSON |
44
+ | `{ error }` | a tool result marked as error, with that JSON |
45
+ | silence | an error result, `{ error: 'silence' }`: the work may have happened |
46
+ | unreached | an error result, `{ error: 'unreached' }`: nothing was delivered |
47
+ | another word | an error result, `{ error: word }`, with what the side says of it |
48
+ | `removed` | at session open: 401, the token forgotten, the exchange again |
49
+
50
+ Silence is never turned into a retry by the envelope. The model decides, and
51
+ the two words give it what it needs to decide.
52
+
53
+ ## Flows
54
+
55
+ ### A remote MCP client connects
56
+
57
+ ```
58
+ client mcp. route front desk user being avatar
59
+ |-- OAuth ------->| | | |
60
+ | |-- device(proof) --->| | |
61
+ | | |-- invite ------->| |
62
+ | | |<-- invitation ---| |
63
+ | |<-- invitation ------| | |
64
+ | |-- boot or find avatar for (human, client) ------------>|
65
+ | |-- knock(invitation) --------------------------------->|
66
+ | | | |<-- knock ------|
67
+ | | | |--- answer ---->|
68
+ | | take |
69
+ |<-- initialize --| |
70
+ |-- tools/list -->|-- ask() ------------------------------------------------>|--> user being
71
+ |<-- tool list ---|<-- describe for this asker --------------------------------|
72
+ |-- tools/call -->|-- ask(name, args, { time }) ---------------------------->|--> user being
73
+ |<-- result ------|<-- object | error | silence | unreached ------------------|
74
+ ```
75
+
76
+ ### A local client connects over stdio
77
+
78
+ Identical, with the differences that there is no OAuth, the proof is that the
79
+ process runs as the human on the device, and the avatar lives in the laptop
80
+ harbor. No droplet is needed for this. A dialer harbor is enough, because the
81
+ model is on the same machine as the ward it talks to.
82
+
83
+ ### Two ways a model is driven
84
+
85
+ Every model is driven in one of two ways, and the avatar is the same in
86
+ both. What differs is who starts the loop.
87
+
88
+ **The human is the loop.** You are in a chat and the model is your hands.
89
+ You say "book the visit", it chains availability, calendar and book, each
90
+ one an ask through your gate under the id you minted for it, and acme sees
91
+ your id. Without the model you would press the buttons yourself, and that
92
+ sentence is literally true: the screen is the user being's describe spoken
93
+ as forms, the tool list is the same describe spoken as tools. Two sides onto
94
+ one gate, and neither can do a thing the other cannot.
95
+
96
+ **The world is the loop.** An agent lives alone in a folder with its own
97
+ constitution, a `CLAUDE.md` or the equivalent for any other model, and is
98
+ woken by events. The flow:
99
+
100
+ 1. **The trigger is a reverse ask.** The user being holds a standing to the
101
+ agent's avatar and asks it when something happens. The avatar's `answer`
102
+ is the side: it starts a run, `claude -p` or any CLI agent, in the
103
+ agent's folder with the event as the prompt.
104
+ 2. **Ack now, report later.** A run takes minutes; an ask waits thirty
105
+ seconds by default and five at the ceiling, and a late reply is not read.
106
+ So the avatar answers at once with an acknowledgement, and when the run
107
+ ends it asks the user being back on its own standing with the result.
108
+ A run never holds an ask open.
109
+ 3. **One run at a time.** Asks are concurrent by design, so two events would
110
+ start two runs in one folder. The avatar serializes runs per agent: one
111
+ constitution, one train of thought. A queue in her cells is enough.
112
+ 4. **Roles stay apart in one agent.** The run that does chores goes through
113
+ the gate as an occupant. If the same agent also boots beings, it holds the
114
+ owner pilot, and that is the estate: a separate run, through the pilot,
115
+ never the one doing the work.
116
+
117
+ Two layers of permission exist here, and only one is real. The gate on the
118
+ user being decides what the agent may ask, in hard terms. The constitution
119
+ decides how it behaves, in soft terms. A permission never lives in the soft
120
+ layer: a run gone wrong can do exactly what the gate shows the asker
121
+ "agent", so that list is the narrowest that still gets the job done.
122
+
123
+ The conventions this side pins, in `packages/dock/mcp/agent.ts`:
124
+
125
+ | the agent side does | as |
126
+ | --------------------- | --------------------------------------------------------------- |
127
+ | the event | the object the user being pushed; `notify` answers at once |
128
+ | the queue | her inbox, in her cells, so it survives the side and a reboot |
129
+ | a run | a function of the event, or a process in the agent's folder |
130
+ | the process | the command, then the event as JSON as its last argument |
131
+ | its result | `{ output }` from stdout, or `{ error, stderr }` on a bad exit |
132
+ | a run that throws | `{ error }` with what it said |
133
+ | the callback | `report({ event, result })` on the user being, gated to devices |
134
+ | a report unanswered | dropped, never retried, never held |
135
+ | two events | one after the other, first in first out |
136
+ | on a device | `<dir>/agents.json`: identity to command, args and folder |
137
+ | who wakes her | the desk, or a device the human allowed to wake at the exchange |
138
+
139
+ An event leaves the queue before its run starts, so a crash mid-run loses that
140
+ event and never runs it twice. A run costs the human's quota, so the agent side
141
+ never starts one on its own: only a push starts a run, one per event, never
142
+ retried, and the command in `agents.json` names the model and the tools it may
143
+ have; on the lab that is Haiku and no tools. `report` is the one ask this step
144
+ added to the user being: every device may say what a run of its found, and she
145
+ keeps it in her cells for whoever renders her. The proof is
146
+ `packages/dock/test/agent.test.ts` with a fake run and a fake process.
147
+
148
+ ### A model that does not speak MCP
149
+
150
+ OpenAI-style function calling, Kimi, LM Studio, OpenRouter, anything with a
151
+ tools array. The side is a **runner**: a loop you own that holds the avatar's
152
+ standing on one side and the model's API on the other.
153
+
154
+ - The runner calls the empty ask and hands the model `asks[]` as its tool
155
+ definitions. The schema is already JSON Schema 2020-12; most APIs take it
156
+ verbatim, and the runner narrows it where one does not.
157
+ - Each tool call from the model becomes a named ask on the avatar's standing.
158
+ - Each answer, error, silence or word goes back as the tool result, in the
159
+ same mapping as above.
160
+
161
+ The runner is not a second architecture. It is an MCP client written inline,
162
+ and it uses the same avatar, the same invitation, the same gate. A model's
163
+ capability changes how well it uses the tools and nothing about what it can
164
+ reach: a weak model calls the wrong ask and gets an error object.
165
+
166
+ What moved in-process is the host. An MCP client drives the loop from
167
+ outside; a completions API cannot, so the runner drives it. It holds one
168
+ conversation for as long as it serves, and two things start a turn in it:
169
+ a line from the human, which is the human being the loop, and a push from
170
+ the user being, which is the world being the loop. The conventions the
171
+ runner pins, in `packages/dock/mcp/runner.ts`:
172
+
173
+ | the runner does | as |
174
+ | ------------------------------ | ------------------------------------------------------------ |
175
+ | the model | a base URL, a model name, a bearer from the device if wanted |
176
+ | her describe | `tools[]` of `type: function`, name, description, parameters |
177
+ | an ask with no properties | sent with `properties: {}`; LM Studio refuses it bare |
178
+ | a tool call | a named ask; arguments that are not JSON are an error object |
179
+ | a tool result | one `tool` message: JSON, or the text of the two words |
180
+ | a push | a `user` message holding the object; it starts a turn |
181
+ | a turn | until the model answers with no tool call, or ten calls |
182
+ | two turns | never at once: the second waits, one train of thought |
183
+
184
+ Ten is a ceiling, not a permission: a turn that ends there has no final
185
+ text, and the gate decided everything the model reached. The proof is
186
+ `packages/dock/test/runner.test.ts` over a scripted endpoint on loopback, and the
187
+ same suite runs one live turn when `QUO_MODEL_URL` names a real endpoint.
188
+
189
+ From a terminal the runner is `quo run --as NAME --url URL --model NAME`:
190
+ the same pipe as `quo side`, with the model named in the hello line, so
191
+ the daemon runs the runner beside the avatar it boots for the name. A line
192
+ typed is a turn; the model's final text is a line back; a push from the
193
+ user being starts a turn of its own and its text comes back the same way.
194
+ A turn that ends at the ceiling, `--turns`, has no final text and comes
195
+ back as an empty line; a model that fails comes back as one line holding
196
+ an error object. The bearer, `QUO_MODEL_KEY`, is read by the command from
197
+ the device and crosses only the local socket.
198
+
199
+ ### A model as the owner
200
+
201
+ An **owner pilot** is an MCP server over stdio that reaches a ward's owner
202
+ asks, either as the root through the daemon's socket on the device, or as an
203
+ owner the root invited, through a standing over the sealed door, and exposes
204
+ four tools and no more:
205
+
206
+ ```
207
+ boot({ key, class, public? })
208
+ invite({ being, id })
209
+ knock({ being | { boot, key }, id, invitation, method?, args? })
210
+ remove({ being, id })
211
+ ```
212
+
213
+ Plus one read, `census()`, which is the empty ask. Every call is logged
214
+ with what it made. The pilot is handed to one local agent by the device's
215
+ own rules. A remote pilot is a standing at the ward, and still not a route:
216
+ a model without a ward of its own cannot be an owner anywhere. `quo pilot
217
+ --via S` is that pilot: S is a standing the user being here holds at the
218
+ far ward, taken when the root here knocked for her with an invitation the
219
+ far root minted on its ward's pk, and every tool call is a sealed ask
220
+ there, answered as the far ward answers an owner at its door.
221
+
222
+ The owner does not do the work. A Claude Code session that boots a being and
223
+ wants to try it connects to an avatar like anyone else and asks through the
224
+ gate. If it could act as the owner, the arrival would be unnamed and no gate
225
+ would have judged it.
226
+
227
+ ### The estate agent
228
+
229
+ The spec has no estate on purpose. On the droplet it is `claude -p`, or any
230
+ CLI agent, under cron, holding the owner pilot and one avatar:
231
+
232
+ - through the pilot: read the census, boot again a being whose digest is
233
+ null this run, report a class the harbor no longer holds.
234
+ - through the avatar: run the chores the human gated to it.
235
+
236
+ Nothing about the estate agent is inside a ward. Lose the cron and the world
237
+ keeps running; it just stops being looked after.
238
+
239
+ ### A model as the developer
240
+
241
+ A local agent writes a being class to disk. The harbor holds class bodies, so
242
+ a new class is a file the harbor loads and a name the owner boots. The
243
+ developer role is a file system and an editor, plus the owner pilot to boot.
244
+ Nothing crosses the wire.
245
+
246
+ ## Invariants of this side
247
+
248
+ On top of the shared ones in the trunk:
249
+
250
+ 1. A run never holds an ask open: it acknowledges, and asks back with the
251
+ result.
252
+ 2. A permission lives in a gate, never in a constitution.
253
+ 3. The runner and the MCP server are two envelopes over one avatar; no
254
+ behaviour lives in either that the other lacks.
255
+
256
+ ## Glossary of this side
257
+
258
+ - **constitution**: the folder and instructions an autonomous agent runs
259
+ under; governance, never permission.
260
+ - **runner**: an inline MCP client for a model that speaks function calling;
261
+ it drives the loop the MCP client would, over one conversation.
262
+ - **owner pilot**: an MCP server over stdio exposing the four owner asks.