Skip to content

feat(api): per-agent REST surface at /api/me/agents#4104

Merged
EmmaLouise2018 merged 7 commits into
mainfrom
EmmaLouise2018/register-agent-api
May 5, 2026
Merged

feat(api): per-agent REST surface at /api/me/agents#4104
EmmaLouise2018 merged 7 commits into
mainfrom
EmmaLouise2018/register-agent-api

Conversation

@EmmaLouise2018

@EmmaLouise2018 EmmaLouise2018 commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds GET / POST / PATCH /:url / DELETE /:url 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 existing visibility gate and server-side type resolution from member-profiles.ts. Type-resolution flips (the smuggle-protection events themselves) are audit-logged via logResolvedTypeChanges; pure renames and deletes are not — same scope as the bulk PUT /api/me/member-profile path.
  • Per-org concurrency: writes go through BEGINSELECT … FOR UPDATE on member_profiles → mutator → UPDATECOMMIT so two parallel POSTs/PATCHes/DELETEs serialize cleanly.
  • Multi-org callers may pass ?org=… to target a non-primary organization; verification goes through resolveUserOrgMembership. Non-members get 403.
  • DELETE /api/me/agents/{url} returns 409 unpublish_first when the agent is currently public, so the registry catalog and the published brand.json cannot silently disagree. Caller PATCHes visibility: "private" first (which runs the manifest-write path), then re-issues the DELETE.
  • PATCH /api/me/agents/{url} with a body url that disagrees with the path returns 400 url_immutable rather than dropping the rename silently.
  • POST is rate-limited via brandCreationRateLimiter, matching /api/brands/save.
  • Documents the surface with auto-generated Mintlify pages under docs/registry/api-reference/member-agents/… (group ordered first in the side nav so it sits directly below the prose Registering an agent page). The OpenAPI spec is generated via Zod through server/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 via PUT /api/me/member-profile. The new agent endpoints return 404 Profile not found until then.

Files changed

File Change
server/src/routes/member-agents.ts new — createMemberAgentsRouter() + applyMemberAgentMutation() helper
server/src/schemas/member-agents-openapi.ts new — Zod schemas + registry.registerPath() calls (kept out of the route file so the spec generator's import graph doesn't pull middleware/auth.ts)
server/src/http.ts mount router at /api/me/agents; pass workos
server/tests/integration/member-agents-api.test.ts 14 integration tests (auth, idempotency, tier downgrade, ?org=, type-smuggle, DELETE 409 unpublish_first, PATCH 400 url_immutable, etc.)
scripts/generate-openapi.ts side-effect import + new Member Agents tag, ordered first
static/openapi/registry.yaml regenerated — adds 4 ops + 7 component schemas + the new tag. Note: the regen also catches up an incidental upstream drift — sponsored-intelligence was added to the AdCPSpecialism enum in #3964 but the YAML on main was stale. Not from this PR's logic.
docs/registry/registering-an-agent.mdx "Programmatic registration" section — short table linking to the auto-generated reference pages, plus prerequisites
.changeset/member-agents-rest-api.md "adcontextprotocol": minor (published-API delta)

Behavior

  • Auth: requireAuth — WorkOS session cookie or Bearer API key (sk_… / wos_api_key_…).
  • Org scoping: ?org= for multi-org parity with PUT /api/me/member-profile; defaults to primary org.
  • POST: idempotent on url. New entry → 201. Existing url → 200. Rate-limited.
  • PATCH: url field cannot be mutated; supplying a divergent url in the body returns 400 url_immutable.
  • DELETE: 204 on success; 404 for unknown url; 409 unpublish_first for currently-public agents.
  • Visibility downgrade: non-API-tier callers asking for public get members_only plus a visibility_downgraded warning in the response body.

Test plan

  • npx tsc --project server/tsconfig.json --noEmit clean
  • npm 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-links clean
  • Integration tests run in docker-e2e (require Postgres) — verify in CI
  • Manual smoke against staging once deployed: register an agent via curl with a real API key + ?org=

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
@EmmaLouise2018 EmmaLouise2018 requested a review from bokelley as a code owner May 4, 2026 21:49
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 bokelley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Concurrency / lost update. POST, PATCH, DELETE all do GET-merge-PUT on profile.agents[] with no row lock or optimistic-concurrency token. Two CI jobs registering different agents at the same time will clobber one. Existing PUT /api/me/member-profile has the same property, but it's more likely to bite here because the pitch is "use this from CI / deploy hooks." Either advisory-lock on workos_organization_id around the read-modify-write, or document the constraint ("serialize calls per-org").

  2. Multi-org users degrade silently. resolveOrgOrSendError uses resolvePrimaryOrganization(req.user.id). The existing PUT /api/me/member-profile path supports ?org=… plus listOrganizationMemberships + 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.

  3. Type-smuggle protection is claimed but untested. PR body and OpenAPI explicitly say "the server resolves type from the capability snapshot — a client can't pin buying on a sales agent." None of the 9 integration tests assert this. Add one: POST with type: 'buying' against a fixture URL whose snapshot says sales, expect persisted type: 'sales'. Without it, a future refactor could quietly drop resolveAgentTypes and tests still pass.

Nits

  1. Audit-log claim is narrower than the wording. logResolvedTypeChanges only 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."

  2. PR body says PUT /api/me/member-profile for prereq creation, but the route handler error message and the doc both say POST. Code/docs match (creation is POST, update is PUT) — just the PR description drifts.

  3. The sponsored-intelligence specialism enum addition in registry.yaml looks like incidental regenerator drift. Worth a sentence in the PR confirming it's expected and not from this PR's logic.

  4. The OpenAPI description on DELETE — "subsequent visibility reconciliation" — is hand-wavy. Either name the mechanism (invalidateMemberContextCache triggers brand.json regeneration 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.
@EmmaLouise2018 EmmaLouise2018 requested a review from bokelley May 5, 2026 01:01
@EmmaLouise2018 EmmaLouise2018 merged commit c2e3edf into main May 5, 2026
19 of 20 checks passed
@EmmaLouise2018 EmmaLouise2018 deleted the EmmaLouise2018/register-agent-api branch May 5, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(registry): expose member agent registration via authenticated REST API

2 participants