oficina / meet Public
Meet slice 5: the drive surface — Implementation Plan
> 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.
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.
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.
Tech Stack: Go 1.25 (rastrillo v0.26.0, chi, gorm), rastrillo migration generate, the vendored ui partials.
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).
Global Constraints
CGO_ENABLED=0;make cion every task;make racewhere named; the browser gate is untouched by this slice and must stay 31/31 on the final head (run once with the sandbox off).- Schema change: the
agentstable only, added withrastrillo migration generateafter editingmodels.go; read the SQL it writes; never edit a shipped migration. - Every query touching user-owned rows goes through
scope.Ownedwhere the framework applies it; a row that isn't yours 404s, never 403s. Never bind a request body onto a GORM model: explicitmap[string]any+.Selectallowlist, or a typed request struct copied field by field. - 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>andagent:<id>(§7.2). - 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. - 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). - 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_atRFC 3339 or absent. - 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. - A per-agent token bucket on the API: 60 requests per minute, burst 60, 429 with
Retry-After(reuserateLimiterwith keyagent:<id>). - 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.mdunder "## Slice 5". - Screens work without JavaScript; the Settings page and its confirm pages are plain forms.
- Commits:
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit, files by name, never.claude/,node_modulesorpackage-lock.json; trailersCo-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>andClaude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2.
---
File structure
internal/calls/models.go—Agent;internal/calls/migrations/0002_agents.sql(generated).internal/calls/agents.go— the token, store methods:createAgent,agentsFor,revokeAgent,agentByToken,agentByID;principal.internal/calls/handlers_settings.go,templates/settings.html,templates/agent_once.html— the Settings page, the mint result, the revoke confirm page.internal/calls/api.go— the bearer middleware,apiJSON, the five operations;internal/calls/hub/count.go—AdmittedCount(sqid).internal/calls/store.go(meetingViewgains the creator's principal),templates/index.html(the badge).internal/callstest/agents_test.go,api_test.go.docs/superpowers/reviews/2026-09-11-slice-5-record.md, spec §15.17.
---
Task 1: The agents table and store
Files:
- Modify:
internal/calls/models.go(addAgent, register inModels),internal/calls/migrations/(generate0002_agents.sql) - Create:
internal/calls/agents.go,internal/calls/agents_test.go
Interfaces:
- Produces: ``
go type Agent struct { ID int64 Name stringgorm:"not null"OperatorPersonID int64gorm:"index;not null"TokenHash stringgorm:"uniqueIndex;not null"CreatedAt time.Time RevokedAt *time.Time } func newAgentToken() (token, hash string, err error) // token "mt_" + base64url(32 random bytes); hash = hex(SHA-256(token)) func hashAgentToken(token string) string func (s *store) createAgent(ctx, name string, operator Person) (Agent, string /* token, once */, error) func (s *store) agentsFor(ctx, operator Person) ([]Agent, error) // newest first, revoked included func (s *store) revokeAgent(ctx, id int64, operator Person) (bool, error) // false when not the operator's or already revoked func (s *store) agentByToken(ctx, token string) (Agent, Person /* operator */, bool, error) // false for unknown or revoked func (s *store) agentByID(ctx, id int64) (Agent, Person, bool, error) func principal(ag Agent, operator Person) string // "<name>~<operator email>" func actorForAgent(ag Agent) string // "agent:<id>"`` - Name rules: 1..64 characters after trimming, letters, digits,
-and_only (^[A-Za-z0-9_-]{1,64}$), because it appears in the principal before~.
- [ ] Step 1: Write the failing tests (
internal/calls/agents_test.go; use the package's existing test store helper, seestore_test.go)
package meet
import (
"context"
"strings"
"testing"
)
func TestAgentTokenIsShownOnceAndStoredHashed(t *testing.T) {
s := testStore(t)
ctx := context.Background()
op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
ag, token, err := s.createAgent(ctx, "claude", op)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(token, "mt_") || len(token) < 40 {
t.Fatalf("token shape %q", token)
}
if ag.TokenHash != hashAgentToken(token) || ag.TokenHash == token || len(ag.TokenHash) != 64 {
t.Fatalf("stored %q for %q", ag.TokenHash, token)
}
got, opGot, ok, err := s.agentByToken(ctx, token)
if err != nil || !ok || got.ID != ag.ID || opGot.ID != op.ID {
t.Fatalf("lookup %+v %+v %v %v", got, opGot, ok, err)
}
if _, _, ok, _ := s.agentByToken(ctx, "mt_nope"); ok {
t.Fatal("an unknown token must not resolve")
}
if principal(ag, op) != "claude~paul@example.com" {
t.Fatalf("principal %q", principal(ag, op))
}
if actorForAgent(ag) != "agent:"+itoa(ag.ID) {
t.Fatalf("actor %q", actorForAgent(ag))
}
}
func TestRevokedAgentDoesNotResolveAndOnlyTheOperatorMayRevoke(t *testing.T) {
s := testStore(t)
ctx := context.Background()
op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
other := Person{ID: 2, Email: "bea@example.com", Name: "bea"}
ag, token, _ := s.createAgent(ctx, "claude", op)
if ok, _ := s.revokeAgent(ctx, ag.ID, other); ok {
t.Fatal("another person revoked the agent")
}
if ok, _ := s.revokeAgent(ctx, ag.ID, op); !ok {
t.Fatal("the operator could not revoke")
}
if ok, _ := s.revokeAgent(ctx, ag.ID, op); ok {
t.Fatal("revoking twice must report false")
}
if _, _, ok, _ := s.agentByToken(ctx, token); ok {
t.Fatal("a revoked token must not resolve")
}
list, _ := s.agentsFor(ctx, op)
if len(list) != 1 || list[0].RevokedAt == nil {
t.Fatalf("list %+v", list)
}
if list2, _ := s.agentsFor(ctx, other); len(list2) != 0 {
t.Fatal("another person's list must be empty")
}
}
func TestAgentNameRules(t *testing.T) {
s := testStore(t)
ctx := context.Background()
op := Person{ID: 1, Email: "paul@example.com"}
for _, bad := range []string{"", " ", "with space", "tilde~x", "colon:x", strings.Repeat("a", 65), "é"} {
if _, _, err := s.createAgent(ctx, bad, op); err == nil {
t.Fatalf("accepted name %q", bad)
}
}
if _, _, err := s.createAgent(ctx, " claude-2_ok ", op); err != nil {
t.Fatalf("trimmed valid name refused: %v", err)
}
}(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.)
- [ ] Step 2: Run to see them fail: compile error.
- [ ] Step 3: Implement. Add
Agenttomodels.goand to theModelslist, then runrastrillo migration generate(throughgo run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration generatefrom the module root, as the Makefile's migration-check does) and read the SQL it writes intomigrations/0002_agents.sql: it must be aCREATE TABLE agentswith the unique index ontoken_hashand the index onoperator_person_id, nothing else. Thenagents.go:
package meet
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"regexp"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
var errAgentName = errors.New("calls: an agent name is 1 to 64 letters, digits, - or _")
// newAgentToken mints "mt_" + 32 random bytes, base64url without
// padding, and its SHA-256 hex. The token exists in plaintext only on
// its way to the one page that shows it (§8.1).
func newAgentToken() (token, hash string, err error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", "", err
}
token = "mt_" + base64.RawURLEncoding.EncodeToString(b[:])
return token, hashAgentToken(token), nil
}
func hashAgentToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func principal(ag Agent, operator Person) string { return ag.Name + "~" + operator.Email }
func actorForAgent(ag Agent) string { return "agent:" + strconv.FormatInt(ag.ID, 10) }
func (s *store) createAgent(ctx context.Context, name string, operator Person) (Agent, string, error) {
name = strings.TrimSpace(name)
if !agentNameRe.MatchString(name) {
return Agent{}, "", errAgentName
}
token, hash, err := newAgentToken()
if err != nil {
return Agent{}, "", err
}
ag := Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()}
if err := s.g.WithContext(ctx).Create(&ag).Error; err != nil {
return Agent{}, "", err
}
return ag, token, nil
}
func (s *store) agentsFor(ctx context.Context, operator Person) ([]Agent, error) {
var out []Agent
err := s.g.WithContext(ctx).Where("operator_person_id = ?", operator.ID).Order("created_at DESC, id DESC").Find(&out).Error
return out, err
}
// revokeAgent revokes one of the operator's own agents. False when the
// row is not theirs or is already revoked: a 404 shape, never a 403.
func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (bool, error) {
res := s.g.WithContext(ctx).Model(&Agent{}).
Where("id = ? AND operator_person_id = ? AND revoked_at IS NULL", id, operator.ID).
Update("revoked_at", s.now().UTC())
return res.RowsAffected == 1, res.Error
}
// agentByToken resolves a bearer to its agent and operator. Unknown and
// revoked both answer false; the caller must not tell them apart.
func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) {
var ag Agent
err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Agent{}, Person{}, false, nil
}
if err != nil {
return Agent{}, Person{}, false, err
}
var op Person
if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Agent{}, Person{}, false, nil
}
return Agent{}, Person{}, false, err
}
return ag, op, true, nil
}
func (s *store) agentByID(ctx context.Context, id int64) (Agent, Person, bool, error) {
var ag Agent
err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Agent{}, Person{}, false, nil
}
if err != nil {
return Agent{}, Person{}, false, err
}
var op Person
if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
return Agent{}, Person{}, false, err
}
return ag, op, true, nil
}
var _ = time.Now- [ ] Step 4: Gate:
make ci(its migration check must agree),make race. - [ ] Step 5: Commit
agents: the table, the once-shown token, the storewith the trailers, files by name.
---
Task 2: Settings: mint, show once, revoke
Files:
- Create:
internal/calls/handlers_settings.go,internal/calls/templates/settings.html,internal/calls/templates/agent_once.html - Modify:
internal/calls/app.go(routes inside therequireMembergroup:GET /settings,POST /settings/agents,GET /settings/agents/{id}/revoke,POST /settings/agents/{id}/revoke),internal/calls/templates/layout.htmlor the topbar (a "Settings" link for members),internal/calls/templates/index.html(nothing yet; Task 4) - Test:
internal/callstest/agents_test.go
Interfaces:
GET /settingslists the member's agents (name, created, revoked or the principal) with a create form (name) and a Revoke link per live agent;POST /settings/agentsmints and rendersagent_once.htmlwith the token in a readonly input (thelink_once.htmlpattern) and the principal;GET /settings/agents/{id}/revokeis the confirm page (ui/confirm-form);POSTrevokes and 303s to/settings; a foreign or unknown id 404s.- 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."
- [ ] Step 1: Write the failing tests (callstest, the harness's
memberWithMeeting/browser/signIn/get/posthelpers): a member sees an empty Settings page; creating an agent renders the token once (the page containsmt_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). - [ ] Step 2: Implement the handlers in the file's existing style (
page(r, data),ErrorPage,actorFor(p)), the templates with the vendored partials (fieldfor the name,confirm-formfor revoke,back-nav,page-header,empty-state), and the Settings link in the shell for members. - [ ] Step 3: Gate, catalogue the strings, commit
settings: mint an agent token once, revoke it.
---
Task 3: The API: bearer, reach, five operations
Files:
- Create:
internal/calls/api.go,internal/calls/hub/count.go(+ test),internal/callstest/api_test.go - Modify:
internal/calls/app.go(a route group/api/v1outside the session group, withcsrf.ProtectNOT applied? Keep the router's globalcsrf.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 beforecsrf.Protectand say so),internal/calls/ratelimit.goonly if a constructor is needed.
Interfaces:
func (h *Hub) AdmittedCount(sqid string) int— underh.mu, the number of admitted participations in the room, 0 for no room.- Middleware
a.requireAgent: readsAuthorization, requiresBearer mt_…(else 401{"error":"unauthorized"}),agentByToken(false → 404 not-found body),a.isMember(operator)(false → the same 404), thena.apiLimiter.allow("agent:"+id, now)(false → 429 withRetry-After), putsagentandoperatoron the context. Cookies are never read here. Logs one line per refusal withagent_id(only when resolved) andoutcome; never the token or its hash; nothing on success. - JSON shapes: -
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>"}]}-POST /api/v1/meetingsbody{"title":"…","starts_at":"RFC3339"?}→201 {"meeting":{…},"link":"https://…/j/<token>"}(the link URL througha.linkURL(token)), actoragent:<id>-POST /api/v1/meetings/{sqid}/links→200 {"meeting":{…},"link":"…"}; ended meeting → 409{"error":"ended"}-POST /api/v1/meetings/{sqid}/end→200 {"meeting":{…}}; already ended → 409{"error":"ended"}-GET /api/v1/meetings/{sqid}→200 {"meeting":{…},"admitted":N}(admittedpresent only whileended_atis null; it ishub.AdmittedCount) - 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. - Every responseContent-Type: application/json,Cache-Control: no-store.
- [ ] Step 1: Write the failing tests (
api_test.go, with a helpermintAgent(t, x, c) (token string)that drives Settings as the member):
func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T) // no header → 401; a signed-in cookie alone → 401; "Bearer nope" → 404 with the not-found body; Basic → 401
func 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
func 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"
func 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)
func 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
func TestAPIRateLimitsPerAgent(t *testing.T) // 61 GETs → the 61st is 429 with Retry-After; a second agent is unaffected
func 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)Also hub/count_test.go: AdmittedCount is 0 for no room, counts admitted only, and never a waiting guest.
- [ ] Step 2: Implement
api.gowith a smallapiJSON(w, status, body)writer, the middleware, and five handlers that call the store (listMeetings,createMeeting,meetingBySqid,replaceLink,endMeeting) withactorForAgent(ag);hub/count.go. Refusal bodies through oneapiNotFound()constant map. No handler reads a cookie. The per-agent limiter:newRateLimiter(1, 60, 4096)(or the existing constructor's shape) stored onappasapiLimiter. - [ ] Step 3: Gate:
make ci,make race. Commitapi: /api/v1 meetings for agents, reach re-derived per request.
---
Task 4: The badge on the list, the record, the spec note
Files:
- Modify:
internal/calls/store.go(meetingViewgainsCreatedByPrincipal stringandByAgent bool, filled by resolvingcreated_byprefixesagent:<id>throughagentByID;person:<id>stays as today),internal/calls/templates/index.html(a robot badge throughui/badgewith the lucideboticon frominternal/calls/icons, and the principal after the title: "by claude~paul@example.com"),internal/calls/icons/(addbotif 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 - Test:
internal/callstest/api_test.go's badge test now passes;internal/callstest/room_test.gountouched.
- [ ] Step 1: Implement the view and the badge; make Task 3's badge test pass.
- [ ] 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,
agentstable). - [ ] Step 3: Gate:
make ci,make race, andmake browseronce (sandbox off) on the head. Commitlist: the robot badge and the principal on an agent's meetings; docs.
---
Self-review
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.
Placeholders: none; Task 2's handlers follow the named files' existing style with the partials named.
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.
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.