oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1# Meet slice 5: the drive surface — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A member mints an agent token from Settings; an agent with that token can list, create, link and end meetings over a JSON API, exactly as far as its operator's current standing reaches and never any further; every write it makes is marked as the agent's on the meeting list.
6
7**Architecture:** One new table, `agents`, holds a name, the operator, the SHA-256 of a token shown once, and a revocation time. A bearer middleware on `/api/v1/*` finds the agent by token hash, refuses a revoked one, and re-derives the operator's member standing on every request (§8.1: an operator who leaves takes the agent's reach with them, no revocation step). The five operations of §8.2 reuse the store's existing meeting functions with actor `agent:<id>`. The list shows a robot badge and the principal `<name>~<operator email>` on meetings an agent created. Cookies are never honoured on the API and bearers never on HTML routes.
8
9**Tech Stack:** Go 1.25 (rastrillo v0.26.0, chi, gorm), `rastrillo migration generate`, the vendored ui partials.
10
11**Spec:** `docs/superpowers/specs/2026-09-07-meet-design.md` revision 9: §2.5 (the `/api/v1/*` row), §7.2 (actors), §8 (the drive surface: §8.1 principals and reach, §8.2 operations, §8.3 MCP deferred, §8.4 native clients), §12 item 5, §13 (the slice-5 gate: the API reach check after an operator's removal), §15. Suite discussion 4 (agents as their own principal `claude~paul` style, bound to the operator's reach).
12
13## Global Constraints
14
15- `CGO_ENABLED=0`; `make ci` on every task; `make race` where named; the browser gate is untouched by this slice and must stay 31/31 on the final head (run once with the sandbox off).
16- Schema change: the `agents` table only, added with `rastrillo migration generate` after editing `models.go`; read the SQL it writes; never edit a shipped migration.
17- Every query touching user-owned rows goes through `scope.Owned` where the framework applies it; a row that isn't yours 404s, never 403s. Never bind a request body onto a GORM model: explicit `map[string]any` + `.Select` allowlist, or a typed request struct copied field by field.
18- The token is shown exactly once (the Settings page after minting) and stored only as a SHA-256 hex; it never reaches a log line, a response other than the mint page, or an error message. Actor strings are `person:<id>` and `agent:<id>` (§7.2).
19- On the API: `Authorization: Bearer <token>` only; a cookie is ignored; a missing or malformed bearer is 401 `{"error":"unauthorized"}`; an unknown, revoked, or reach-less token is 404 `{"error":"not-found"}` byte-identical to an unknown meeting's 404, so nothing about token existence or operator standing is observable. On HTML routes a bearer is ignored.
20- Reach is re-derived on every API request: the agent unrevoked **and** the operator currently a member (`isMember`); a change in the allowlist takes effect on the next request (§13's gate).
21- JSON bodies are capped at 16 KB (`http.MaxBytesReader`, 413 `{"error":"too-large"}`); malformed or unknown fields are 400 `{"error":"bad-request"}` (`DisallowUnknownFields`). Titles 1..200 characters, `starts_at` RFC 3339 or absent.
22- The only two responses that ever carry a link token are creation and replacement (§8.2). `GET /api/v1/meetings/{sqid}` returns the count of admitted peers while the meeting runs, never names or chat.
23- A per-agent token bucket on the API: 60 requests per minute, burst 60, 429 with `Retry-After` (reuse `rateLimiter` with key `agent:<id>`).
24- Copy review is waived this round per suite discussion 5; every new string is catalogued in `docs/superpowers/copy/2026-09-10-slice-2-strings.md` under "## Slice 5".
25- Screens work without JavaScript; the Settings page and its confirm pages are plain forms.
26- Commits: `git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit`, files by name, never `.claude/`, `node_modules` or `package-lock.json`; trailers `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2`.
27
28---
29
30## File structure
31
32- `internal/calls/models.go` — `Agent`; `internal/calls/migrations/0002_agents.sql` (generated).
33- `internal/calls/agents.go` — the token, store methods: `createAgent`, `agentsFor`, `revokeAgent`, `agentByToken`, `agentByID`; `principal`.
34- `internal/calls/handlers_settings.go`, `templates/settings.html`, `templates/agent_once.html` — the Settings page, the mint result, the revoke confirm page.
35- `internal/calls/api.go` — the bearer middleware, `apiJSON`, the five operations; `internal/calls/hub/count.go` — `AdmittedCount(sqid)`.
36- `internal/calls/store.go` (`meetingView` gains the creator's principal), `templates/index.html` (the badge).
37- `internal/callstest/agents_test.go`, `api_test.go`.
38- `docs/superpowers/reviews/2026-09-11-slice-5-record.md`, spec §15.17.
39
40---
41
42### Task 1: The `agents` table and store
43
44**Files:**
45- Modify: `internal/calls/models.go` (add `Agent`, register in `Models`), `internal/calls/migrations/` (generate `0002_agents.sql`)
46- Create: `internal/calls/agents.go`, `internal/calls/agents_test.go`
47
48**Interfaces:**
49- Produces:
50 ```go
51 type Agent struct {
52 ID int64
53 Name string `gorm:"not null"`
54 OperatorPersonID int64 `gorm:"index;not null"`
55 TokenHash string `gorm:"uniqueIndex;not null"`
56 CreatedAt time.Time
57 RevokedAt *time.Time
58 }
59 func newAgentToken() (token, hash string, err error) // token "mt_" + base64url(32 random bytes); hash = hex(SHA-256(token))
60 func hashAgentToken(token string) string
61 func (s *store) createAgent(ctx, name string, operator Person) (Agent, string /* token, once */, error)
62 func (s *store) agentsFor(ctx, operator Person) ([]Agent, error) // newest first, revoked included
63 func (s *store) revokeAgent(ctx, id int64, operator Person) (bool, error) // false when not the operator's or already revoked
64 func (s *store) agentByToken(ctx, token string) (Agent, Person /* operator */, bool, error) // false for unknown or revoked
65 func (s *store) agentByID(ctx, id int64) (Agent, Person, bool, error)
66 func principal(ag Agent, operator Person) string // "<name>~<operator email>"
67 func actorForAgent(ag Agent) string // "agent:<id>"
68 ```
69- Name rules: 1..64 characters after trimming, letters, digits, `-` and `_` only (`^[A-Za-z0-9_-]{1,64}$`), because it appears in the principal before `~`.
70
71- [ ] **Step 1: Write the failing tests** (`internal/calls/agents_test.go`; use the package's existing test store helper, see `store_test.go`)
72
73```go
74package meet
75
76import (
77 "context"
78 "strings"
79 "testing"
80)
81
82func TestAgentTokenIsShownOnceAndStoredHashed(t *testing.T) {
83 s := testStore(t)
84 ctx := context.Background()
85 op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
86 ag, token, err := s.createAgent(ctx, "claude", op)
87 if err != nil {
88 t.Fatal(err)
89 }
90 if !strings.HasPrefix(token, "mt_") || len(token) < 40 {
91 t.Fatalf("token shape %q", token)
92 }
93 if ag.TokenHash != hashAgentToken(token) || ag.TokenHash == token || len(ag.TokenHash) != 64 {
94 t.Fatalf("stored %q for %q", ag.TokenHash, token)
95 }
96 got, opGot, ok, err := s.agentByToken(ctx, token)
97 if err != nil || !ok || got.ID != ag.ID || opGot.ID != op.ID {
98 t.Fatalf("lookup %+v %+v %v %v", got, opGot, ok, err)
99 }
100 if _, _, ok, _ := s.agentByToken(ctx, "mt_nope"); ok {
101 t.Fatal("an unknown token must not resolve")
102 }
103 if principal(ag, op) != "claude~paul@example.com" {
104 t.Fatalf("principal %q", principal(ag, op))
105 }
106 if actorForAgent(ag) != "agent:"+itoa(ag.ID) {
107 t.Fatalf("actor %q", actorForAgent(ag))
108 }
109}
110
111func TestRevokedAgentDoesNotResolveAndOnlyTheOperatorMayRevoke(t *testing.T) {
112 s := testStore(t)
113 ctx := context.Background()
114 op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
115 other := Person{ID: 2, Email: "bea@example.com", Name: "bea"}
116 ag, token, _ := s.createAgent(ctx, "claude", op)
117 if ok, _ := s.revokeAgent(ctx, ag.ID, other); ok {
118 t.Fatal("another person revoked the agent")
119 }
120 if ok, _ := s.revokeAgent(ctx, ag.ID, op); !ok {
121 t.Fatal("the operator could not revoke")
122 }
123 if ok, _ := s.revokeAgent(ctx, ag.ID, op); ok {
124 t.Fatal("revoking twice must report false")
125 }
126 if _, _, ok, _ := s.agentByToken(ctx, token); ok {
127 t.Fatal("a revoked token must not resolve")
128 }
129 list, _ := s.agentsFor(ctx, op)
130 if len(list) != 1 || list[0].RevokedAt == nil {
131 t.Fatalf("list %+v", list)
132 }
133 if list2, _ := s.agentsFor(ctx, other); len(list2) != 0 {
134 t.Fatal("another person's list must be empty")
135 }
136}
137
138func TestAgentNameRules(t *testing.T) {
139 s := testStore(t)
140 ctx := context.Background()
141 op := Person{ID: 1, Email: "paul@example.com"}
142 for _, bad := range []string{"", " ", "with space", "tilde~x", "colon:x", strings.Repeat("a", 65), "é"} {
143 if _, _, err := s.createAgent(ctx, bad, op); err == nil {
144 t.Fatalf("accepted name %q", bad)
145 }
146 }
147 if _, _, err := s.createAgent(ctx, " claude-2_ok ", op); err != nil {
148 t.Fatalf("trimmed valid name refused: %v", err)
149 }
150}
151```
152
153(If the package has no `testStore` helper or `itoa`, write minimal ones in the test file, opening an in-memory database through the same `db.Open` path the harness uses and applying `Schema`.)
154
155- [ ] **Step 2: Run to see them fail**: compile error.
156
157- [ ] **Step 3: Implement.** Add `Agent` to `models.go` and to the `Models` list, then run `rastrillo migration generate` (through `go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration generate` from the module root, as the Makefile's migration-check does) and read the SQL it writes into `migrations/0002_agents.sql`: it must be a `CREATE TABLE agents` with the unique index on `token_hash` and the index on `operator_person_id`, nothing else. Then `agents.go`:
158
159```go
160package meet
161
162import (
163 "context"
164 "crypto/rand"
165 "crypto/sha256"
166 "encoding/base64"
167 "encoding/hex"
168 "errors"
169 "regexp"
170 "strconv"
171 "strings"
172 "time"
173
174 "gorm.io/gorm"
175)
176
177var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
178
179var errAgentName = errors.New("calls: an agent name is 1 to 64 letters, digits, - or _")
180
181// newAgentToken mints "mt_" + 32 random bytes, base64url without
182// padding, and its SHA-256 hex. The token exists in plaintext only on
183// its way to the one page that shows it (§8.1).
184func newAgentToken() (token, hash string, err error) {
185 var b [32]byte
186 if _, err := rand.Read(b[:]); err != nil {
187 return "", "", err
188 }
189 token = "mt_" + base64.RawURLEncoding.EncodeToString(b[:])
190 return token, hashAgentToken(token), nil
191}
192
193func hashAgentToken(token string) string {
194 sum := sha256.Sum256([]byte(token))
195 return hex.EncodeToString(sum[:])
196}
197
198func principal(ag Agent, operator Person) string { return ag.Name + "~" + operator.Email }
199
200func actorForAgent(ag Agent) string { return "agent:" + strconv.FormatInt(ag.ID, 10) }
201
202func (s *store) createAgent(ctx context.Context, name string, operator Person) (Agent, string, error) {
203 name = strings.TrimSpace(name)
204 if !agentNameRe.MatchString(name) {
205 return Agent{}, "", errAgentName
206 }
207 token, hash, err := newAgentToken()
208 if err != nil {
209 return Agent{}, "", err
210 }
211 ag := Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()}
212 if err := s.g.WithContext(ctx).Create(&ag).Error; err != nil {
213 return Agent{}, "", err
214 }
215 return ag, token, nil
216}
217
218func (s *store) agentsFor(ctx context.Context, operator Person) ([]Agent, error) {
219 var out []Agent
220 err := s.g.WithContext(ctx).Where("operator_person_id = ?", operator.ID).Order("created_at DESC, id DESC").Find(&out).Error
221 return out, err
222}
223
224// revokeAgent revokes one of the operator's own agents. False when the
225// row is not theirs or is already revoked: a 404 shape, never a 403.
226func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (bool, error) {
227 res := s.g.WithContext(ctx).Model(&Agent{}).
228 Where("id = ? AND operator_person_id = ? AND revoked_at IS NULL", id, operator.ID).
229 Update("revoked_at", s.now().UTC())
230 return res.RowsAffected == 1, res.Error
231}
232
233// agentByToken resolves a bearer to its agent and operator. Unknown and
234// revoked both answer false; the caller must not tell them apart.
235func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) {
236 var ag Agent
237 err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error
238 if errors.Is(err, gorm.ErrRecordNotFound) {
239 return Agent{}, Person{}, false, nil
240 }
241 if err != nil {
242 return Agent{}, Person{}, false, err
243 }
244 var op Person
245 if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
246 if errors.Is(err, gorm.ErrRecordNotFound) {
247 return Agent{}, Person{}, false, nil
248 }
249 return Agent{}, Person{}, false, err
250 }
251 return ag, op, true, nil
252}
253
254func (s *store) agentByID(ctx context.Context, id int64) (Agent, Person, bool, error) {
255 var ag Agent
256 err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error
257 if errors.Is(err, gorm.ErrRecordNotFound) {
258 return Agent{}, Person{}, false, nil
259 }
260 if err != nil {
261 return Agent{}, Person{}, false, err
262 }
263 var op Person
264 if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
265 return Agent{}, Person{}, false, err
266 }
267 return ag, op, true, nil
268}
269
270var _ = time.Now
271```
272
273- [ ] **Step 4: Gate**: `make ci` (its migration check must agree), `make race`.
274- [ ] **Step 5: Commit** `agents: the table, the once-shown token, the store` with the trailers, files by name.
275
276---
277
278### Task 2: Settings: mint, show once, revoke
279
280**Files:**
281- Create: `internal/calls/handlers_settings.go`, `internal/calls/templates/settings.html`, `internal/calls/templates/agent_once.html`
282- Modify: `internal/calls/app.go` (routes inside the `requireMember` group: `GET /settings`, `POST /settings/agents`, `GET /settings/agents/{id}/revoke`, `POST /settings/agents/{id}/revoke`), `internal/calls/templates/layout.html` or the topbar (a "Settings" link for members), `internal/calls/templates/index.html` (nothing yet; Task 4)
283- Test: `internal/callstest/agents_test.go`
284
285**Interfaces:**
286- `GET /settings` lists the member's agents (name, created, revoked or the principal) with a create form (`name`) and a Revoke link per live agent; `POST /settings/agents` mints and renders `agent_once.html` with the token in a readonly input (the `link_once.html` pattern) and the principal; `GET /settings/agents/{id}/revoke` is the confirm page (`ui/confirm-form`); `POST` revokes and 303s to `/settings`; a foreign or unknown id 404s.
287- Strings: "Settings", "Agents", "An agent acts on your behalf with exactly your reach, as <name>~<your address>.", "Name", "Create an agent", "This is the only time this token is shown. Copy it now.", "Revoke", "Revoke <name>? Anything using its token stops working at once.", "Revoked", "No agents yet."
288
289- [ ] **Step 1: Write the failing tests** (callstest, the harness's `memberWithMeeting`/`browser`/`signIn`/`get`/`post` helpers): a member sees an empty Settings page; creating an agent renders the token once (the page contains `mt_` and the principal; a second GET of Settings does not contain the token); the Revoke confirm page and POST work for the operator and 404 for another member (`bea@rslw.com`) and for a guest; the token never appears in the captured log (`newHarnessWithLogger`).
290- [ ] **Step 2: Implement** the handlers in the file's existing style (`page(r, data)`, `ErrorPage`, `actorFor(p)`), the templates with the vendored partials (`field` for the name, `confirm-form` for revoke, `back-nav`, `page-header`, `empty-state`), and the Settings link in the shell for members.
291- [ ] **Step 3: Gate, catalogue the strings, commit** `settings: mint an agent token once, revoke it`.
292
293---
294
295### Task 3: The API: bearer, reach, five operations
296
297**Files:**
298- Create: `internal/calls/api.go`, `internal/calls/hub/count.go` (+ test), `internal/callstest/api_test.go`
299- Modify: `internal/calls/app.go` (a route group `/api/v1` outside the session group, with `csrf.Protect` NOT applied? Keep the router's global `csrf.Protect`: it is origin-based and exempts requests without an Origin; confirm an agent's POST with no Origin header passes; if it does not, mount the API before `csrf.Protect` and say so), `internal/calls/ratelimit.go` only if a constructor is needed.
300
301**Interfaces:**
302- `func (h *Hub) AdmittedCount(sqid string) int` — under `h.mu`, the number of admitted participations in the room, 0 for no room.
303- Middleware `a.requireAgent`: reads `Authorization`, requires `Bearer mt_…` (else 401 `{"error":"unauthorized"}`), `agentByToken` (false → 404 not-found body), `a.isMember(operator)` (false → the same 404), then `a.apiLimiter.allow("agent:"+id, now)` (false → 429 with `Retry-After`), puts `agent` and `operator` on the context. Cookies are never read here. Logs one line per refusal with `agent_id` (only when resolved) and `outcome`; never the token or its hash; nothing on success.
304- JSON shapes:
305 - `GET /api/v1/meetings` → `200 {"meetings":[{"sqid","title","starts_at":RFC3339|null,"ended_at":RFC3339|null,"link_present":bool,"created_by":"<principal or person name>"}]}`
306 - `POST /api/v1/meetings` body `{"title":"…","starts_at":"RFC3339"?}` → `201 {"meeting":{…},"link":"https://…/j/<token>"}` (the link URL through `a.linkURL(token)`), actor `agent:<id>`
307 - `POST /api/v1/meetings/{sqid}/links` → `200 {"meeting":{…},"link":"…"}`; ended meeting → 409 `{"error":"ended"}`
308 - `POST /api/v1/meetings/{sqid}/end` → `200 {"meeting":{…}}`; already ended → 409 `{"error":"ended"}`
309 - `GET /api/v1/meetings/{sqid}` → `200 {"meeting":{…},"admitted":N}` (`admitted` present only while `ended_at` is null; it is `hub.AdmittedCount`)
310 - unknown sqid → 404 `{"error":"not-found"}`; malformed body/unknown field → 400 `{"error":"bad-request"}`; > 16 KB → 413 `{"error":"too-large"}`; title outside 1..200 → 400.
311 - Every response `Content-Type: application/json`, `Cache-Control: no-store`.
312
313- [ ] **Step 1: Write the failing tests** (`api_test.go`, with a helper `mintAgent(t, x, c) (token string)` that drives Settings as the member):
314
315```go
316func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T) // no header → 401; a signed-in cookie alone → 401; "Bearer nope" → 404 with the not-found body; Basic → 401
317func TestAPIReachIsTheOperatorsStandingOnEveryRequest(t *testing.T) // list → 200; x.h.SetAllowlist("bea@rslw.com") (operator removed) → next list 404 with the not-found body; SetAllowlist back → 200 again; revoke via Settings → 404
318func TestAPIListsCreatesLinksAndEnds(t *testing.T) // create with title and starts_at → 201, link contains "/j/mt"?? no: the LINK token (not the agent token); GET the meeting → admitted 0; a member joins through the browser harness and is admitted → admitted 1; replace link → a different link; the old link 404s at /j/; end → 200, ended_at set, admitted absent; end again → 409; links on ended → 409; list shows created_by "claude~paul@example.com"
319func TestAPIBodyRules(t *testing.T) // unknown field 400; title 201 chars 400; empty title 400; bad starts_at 400; 17 KB body 413; a title with "<script>" is stored and listed escaped-as-JSON (a plain string)
320func TestAPILogsCarryNoToken(t *testing.T) // newHarnessWithLogger: a success, a 404 refusal, a 401; the buffer never contains the token, its hash, the link token, or the title
321func TestAPIRateLimitsPerAgent(t *testing.T) // 61 GETs → the 61st is 429 with Retry-After; a second agent is unaffected
322func TestAPIWritesAreMarkedAsTheAgents(t *testing.T) // after a create, the member's HTML list shows the robot badge and the principal for that meeting and not for a member-created one (Task 4's assertion, write it here and let it fail until Task 4)
323```
324
325Also `hub/count_test.go`: `AdmittedCount` is 0 for no room, counts admitted only, and never a waiting guest.
326
327- [ ] **Step 2: Implement** `api.go` with a small `apiJSON(w, status, body)` writer, the middleware, and five handlers that call the store (`listMeetings`, `createMeeting`, `meetingBySqid`, `replaceLink`, `endMeeting`) with `actorForAgent(ag)`; `hub/count.go`. Refusal bodies through one `apiNotFound()` constant map. No handler reads a cookie. The per-agent limiter: `newRateLimiter(1, 60, 4096)` (or the existing constructor's shape) stored on `app` as `apiLimiter`.
328- [ ] **Step 3: Gate**: `make ci`, `make race`. **Commit** `api: /api/v1 meetings for agents, reach re-derived per request`.
329
330---
331
332### Task 4: The badge on the list, the record, the spec note
333
334**Files:**
335- Modify: `internal/calls/store.go` (`meetingView` gains `CreatedByPrincipal string` and `ByAgent bool`, filled by resolving `created_by` prefixes `agent:<id>` through `agentByID`; `person:<id>` stays as today), `internal/calls/templates/index.html` (a robot badge through `ui/badge` with the lucide `bot` icon from `internal/calls/icons`, and the principal after the title: "by claude~paul@example.com"), `internal/calls/icons/` (add `bot` if absent, following the file's pattern), `docs/superpowers/copy/2026-09-10-slice-2-strings.md` ("## Slice 5"), `docs/superpowers/specs/2026-09-07-meet-design.md` (§15.17: the API wire, the 401/404 rule, the per-agent limit, the token shape, the actor and principal), `docs/superpowers/reviews/2026-09-11-slice-5-record.md`
336- Test: `internal/callstest/api_test.go`'s badge test now passes; `internal/callstest/room_test.go` untouched.
337
338- [ ] **Step 1: Implement the view and the badge; make Task 3's badge test pass.**
339- [ ] **Step 2: Write §15.17 and the record (rulings from the ledger, per-task dispositions, carried items, the gate note that the browser gate is unchanged and must still pass on the final head, the deploy note: one migration, applied at boot, `agents` table).**
340- [ ] **Step 3: Gate**: `make ci`, `make race`, and `make browser` once (sandbox off) on the head. **Commit** `list: the robot badge and the principal on an agent's meetings; docs`.
341
342---
343
344## Self-review
345
346**Spec coverage (§12 item 5):** `agents` (1); the five operations (3); the per-request reach check (3, and §13's gate in `TestAPIReachIsTheOperatorsStandingOnEveryRequest`); the badge (4). §8.1's Settings mint and revoke (2). §8.3 deferred by the spec. §8.4: the API is plain JSON with a bearer, usable by a native client.
347
348**Placeholders:** none; Task 2's handlers follow the named files' existing style with the partials named.
349
350**Type consistency:** `Agent`, `createAgent`, `agentsFor`, `revokeAgent`, `agentByToken`, `agentByID`, `principal`, `actorForAgent` (1) used in 2, 3, 4; `hub.AdmittedCount` (3); `meetingView.CreatedByPrincipal/ByAgent` (4) read by 3's badge test.
351
352**Rulings for the controller:** token shape `mt_` + 32 random bytes base64url (Slides has no implementation to copy; §8.1 says instance-minted); unknown, revoked and reach-less tokens all answer the not-found body, a missing bearer 401; `end` and `links` on an ended meeting are 409 `ended`; the per-agent limit is 60/min; `created_by` on the API is the principal for agents and the person's display name for members.
353