feat(api): per-agent REST surface at /api/me/agents#4104
Conversation
Add GET/POST/PATCH/DELETE under /api/me/agents so members can register, list, update, and remove individual agents from CI or scripts via WorkOS API key (Bearer sk_...) — no full-profile round-trip and no Addie/UI dependency. Reuses the same visibility gate, server-side type resolution, and audit log as PUT /api/me/member-profile so the tier check and type smuggle-protection still apply. Closes #4103
Register the four /api/me/agents operations with the shared OpenAPI Zod registry so Mintlify auto-generates per-task reference pages under docs/registry/api-reference/member-agents/, matching the IA of every other registry endpoint. Trim the curl-heavy prose in docs/registry/registering-an-agent.mdx down to a short table of contents that links to the generated pages. The OpenAPI registrations live in server/src/schemas/member-agents-openapi.ts to keep the spec generator's import graph free of middleware/auth.ts — which would otherwise instantiate WorkOS at module load and refuse to run without env vars.
Use /dashboard/api-keys (the canonical path matching every other docs page) instead of /dashboard/settings/api-keys, which 404s.
… an agent Mintlify renders sidebar groups in the order tags appear in the OpenAPI spec. Move Member Agents to the top of TAG_DESCRIPTIONS so the generated group lands directly below the prose Registering an agent page in the registry side nav, instead of at the bottom.
…lic, 400 on patch-rename Surfaced by Brian's review of #4104. Five tightenings of the per-agent REST surface added in the prior commits: 1. SELECT … FOR UPDATE on member_profiles for every write. Two parallel POSTs registering different agents previously raced on the JSONB read-modify-write — same weakness the bulk PUT path has, but parity-with-weak-path doesn't justify reproducing the weakness. Routes now serialize through applyMemberAgentMutation() which wraps the lock + gate + resolveAgentTypes + audit-log + write in one tx. 2. DELETE /api/me/agents/{url} returns 409 unpublish_first when the agent is currently `public`. A public agent is reflected in brand.json — a silent JSONB-only delete would leave the manifest pointing at a deleted entry. Caller has to PATCH visibility=private (or call the existing /publish DELETE) first so the manifest write path runs. 3. PATCH /api/me/agents/{url} returns 400 url_immutable when body.url disagrees with the path, instead of silently dropping the rename. Tell the caller, don't guess silently. 4. POST is rate-limited via brandCreationRateLimiter, matching the precedent for member-write surfaces (/api/brands/save). 5. Changeset bumped from empty to `"adcontextprotocol": minor` — adding 4 ops + 7 component schemas to static/openapi/registry.yaml is a published-API delta, not an internal change. Refactor: applyMemberAgentMutation() collapses ~30 lines of gate+resolve+log+update boilerplate that POST and PATCH had duplicated, and gives DELETE the same lock semantics for free. Tests: existing PATCH-with-body-url-disagreeing-from-path test now asserts 400 (was 200 + silent drop). New DELETE-public test asserts 409 + JSONB unchanged.
bokelley
left a comment
There was a problem hiding this comment.
Solid work overall — small clean façade, reuses the gate/type-resolution helpers from member-profiles.ts, good test coverage, sensible OpenAPI. A few things worth tightening before merge:
Worth addressing
-
Concurrency / lost update.
POST,PATCH,DELETEall do GET-merge-PUT onprofile.agents[]with no row lock or optimistic-concurrency token. Two CI jobs registering different agents at the same time will clobber one. ExistingPUT /api/me/member-profilehas the same property, but it's more likely to bite here because the pitch is "use this from CI / deploy hooks." Either advisory-lock onworkos_organization_idaround the read-modify-write, or document the constraint ("serialize calls per-org"). -
Multi-org users degrade silently.
resolveOrgOrSendErrorusesresolvePrimaryOrganization(req.user.id). The existingPUT /api/me/member-profilepath supports?org=…pluslistOrganizationMemberships+autoLinkByVerifiedDomain. A user with two orgs can manage agents on their primary only via the new surface, with no override. Either add?org=for parity, or note the asymmetry in the doc. -
Type-smuggle protection is claimed but untested. PR body and OpenAPI explicitly say "the server resolves
typefrom the capability snapshot — a client can't pinbuyingon a sales agent." None of the 9 integration tests assert this. Add one: POST withtype: 'buying'against a fixture URL whose snapshot sayssales, expect persistedtype: 'sales'. Without it, a future refactor could quietly dropresolveAgentTypesand tests still pass.
Nits
-
Audit-log claim is narrower than the wording.
logResolvedTypeChangesonly logs when type-resolution flips a value. Deletes and pure-rename PATCHes aren't audit-logged. Consistent with existing PUT, but the PR copy reads as if every write is audited — tighten to "type-resolution changes are audit-logged." -
PR body says
PUT /api/me/member-profilefor prereq creation, but the route handler error message and the doc both sayPOST. Code/docs match (creation is POST, update is PUT) — just the PR description drifts. -
The
sponsored-intelligencespecialism enum addition inregistry.yamllooks like incidental regenerator drift. Worth a sentence in the PR confirming it's expected and not from this PR's logic. -
The OpenAPI description on DELETE — "subsequent visibility reconciliation" — is hand-wavy. Either name the mechanism (
invalidateMemberContextCachetriggersbrand.jsonregeneration on next read) or drop the sentence.
Net: address (1) and (3), fold (2)/(4)/(5)/(6)/(7) into the same push, and ship. Nothing architectural here — all tightening.
…htening Brian's second pass on #4104. Five tightenings: 1. ?org=… parity with PUT /api/me/member-profile. Multi-org callers were silently locked to their primary org; new resolveOrgOrSendError honors ?org= and goes through resolveUserOrgMembership for verification. Non-members get 403. http.ts wires WorkOS through the router config. 2. Type-smuggle integration test. POST type:'buying' against a URL whose agent_capabilities_snapshot.inferred_type is 'sales' now asserts the persisted entry is 'sales' — pinning the resolveAgentTypes contract so a future refactor that drops the helper can't pass tests. 3. Audit-log claim narrowed. Prose was "type resolution + audit log"; logResolvedTypeChanges only fires on type-resolution flips, not on pure renames or deletes. Route docstring + changeset now say so. 4. ?org=… surface in OpenAPI: every operation declares the optional query param + a 403 response for non-membership. Generated reference pages will render the parameter and the auth-aware response. 5. Generator regen catches the upstream sponsored-intelligence specialism enum addition (#3964) into static/openapi/registry.yaml — incidental drift from the OpenAPI spec being stale on main, not from this PR's logic. Tests: - new ?org= happy-path test (member of secondary org → write lands there, primary untouched) - new ?org= 403 test (non-member rejected) - new type-smuggle test asserts server-side resolution wins
…schema CI integration tests failed: my helper inserted into a `status` column that doesn't exist (the real schema has no status — `status: 'active'` is synthesized in the fakeWorkos stub when it reconstructs the WorkOS-shaped response). `email` is also NOT NULL on this table; the INSERT was missing it. Two new tests (?org= happy path + 403 non-member) failed; eight other member-agents-api tests pass. Fix scoped to the helper.
Summary
GET / POST / PATCH /:url / DELETE /:urlunder/api/me/agentsso members can register, list, update, and remove individual agents from CI or scripts via WorkOS API key (Bearer sk_…) — no full-profile round-trip and no Addie/UI dependency.member-profiles.ts. Type-resolution flips (the smuggle-protection events themselves) are audit-logged vialogResolvedTypeChanges; pure renames and deletes are not — same scope as the bulkPUT /api/me/member-profilepath.BEGIN→SELECT … FOR UPDATEonmember_profiles→ mutator →UPDATE→COMMITso two parallel POSTs/PATCHes/DELETEs serialize cleanly.?org=…to target a non-primary organization; verification goes throughresolveUserOrgMembership. Non-members get403.DELETE /api/me/agents/{url}returns409 unpublish_firstwhen the agent is currentlypublic, so the registry catalog and the publishedbrand.jsoncannot silently disagree. Caller PATCHesvisibility: "private"first (which runs the manifest-write path), then re-issues the DELETE.PATCH /api/me/agents/{url}with a bodyurlthat disagrees with the path returns400 url_immutablerather than dropping the rename silently.brandCreationRateLimiter, matching/api/brands/save.docs/registry/api-reference/member-agents/…(group ordered first in the side nav so it sits directly below the proseRegistering an agentpage). The OpenAPI spec is generated via Zod throughserver/src/schemas/member-agents-openapi.ts.Closes #4103.
Prerequisites
An AAO member profile must already exist for the target organization. Create one via
POST /api/me/member-profile; subsequent edits go viaPUT /api/me/member-profile. The new agent endpoints return404 Profile not founduntil then.Files changed
server/src/routes/member-agents.tscreateMemberAgentsRouter()+applyMemberAgentMutation()helperserver/src/schemas/member-agents-openapi.tsregistry.registerPath()calls (kept out of the route file so the spec generator's import graph doesn't pullmiddleware/auth.ts)server/src/http.ts/api/me/agents; passworkosserver/tests/integration/member-agents-api.test.ts?org=, type-smuggle, DELETE 409 unpublish_first, PATCH 400 url_immutable, etc.)scripts/generate-openapi.tsMember Agentstag, ordered firststatic/openapi/registry.yamlsponsored-intelligencewas added to theAdCPSpecialismenum in #3964 but the YAML on main was stale. Not from this PR's logic.docs/registry/registering-an-agent.mdx.changeset/member-agents-rest-api.md"adcontextprotocol": minor(published-API delta)Behavior
requireAuth— WorkOS session cookie or Bearer API key (sk_…/wos_api_key_…).?org=for multi-org parity withPUT /api/me/member-profile; defaults to primary org.url. New entry →201. Existing url →200. Rate-limited.urlfield cannot be mutated; supplying a divergenturlin the body returns400 url_immutable.204on success;404for unknownurl;409 unpublish_firstfor currently-publicagents.publicgetmembers_onlyplus avisibility_downgradedwarning in the response body.Test plan
npx tsc --project server/tsconfig.json --noEmitcleannpm run test:docs-nav(15/15)npm run test:server-unit(3179/3187 — 8 unrelated pre-existing flakes in conformance / idempotency / prospect-triage tests)npm run test:test-dynamic-imports(7/7)npm run check:owned-linksclean?org=