Add an agent surface
A surface is a place agents reach a store from beyond the store's own UCP profile — a partner marketplace or an agent platform's own channel, say; direct_agent is the bundled, zero-config one. A…
Generated from docs/HOWTO-add-surface.md in the Sarvex repository — edit the source there, not this page.
A surface is a place agents reach a store from beyond the store’s own UCP
profile — a partner marketplace or an agent platform’s own channel, say;
direct_agent is the bundled, zero-config one. A surface is declared, not coded: nothing in the enrollment state
machine, the API layer, activation, the sync runner, the serving route, or the
console’s page/store/enrollment component changes. You add modules and one
registration line per plane.
The rule is enforced: internal/archtest TestSurfaceBoundaries fails if a
surface id or surface module is named anywhere but its own module, the shared
id package, or a registration root.
1. Name it once
internal/pkg/ucp/surfaces/surfaces.go: add the id const, add it to All, and
any names its two planes must agree on (artifact id, config keys) — this is the
only shared file, and it is data.
2. Control half — internal/planes/control/surface/<name>/
Write a domain.SurfaceDescriptor and a surface.Registration:
| Descriptor field | Meaning |
|---|---|
PrerequisiteChecks |
named surface.PrerequisiteChecks, in the order the merchant sees them |
RequiredConnectors / RequiredCapabilities |
enforced generically as prerequisite rows |
ConfigSchema |
typed fields (text, bool, string_list, json) → the console draws the form; SetConfig validates against it. Omit for free-form config |
Artifacts |
what Sarvex generates for the store |
ExternalSteps |
what the merchant does elsewhere (owner merchant or surface) |
VerificationSignal |
what proves it worked |
AutoEnroll |
enrolls itself when the store is active (zero-config surfaces only) |
If the artifact is generated by the sync half, don’t write a reader: use
surface.NewSyncedArtifact, surface.NewSyncedSignal and
surface.NewArtifactExistsCheck over surface.SyncedArtifactReader.
Register it: one line in surface/all/all.go.
3. Sync half (only if Sarvex generates an artifact) — internal/planes/sync/surface/<name>/
Implement surface.Generator (SurfaceID, ArtifactID, Extension,
Generate(ctx, Input) (Output, error)) — deterministic, side-effect free; its
Input carries the merchant’s surface config and the store’s products. Register
it: one line in sync/surface/all/all.go. The runner does the rest: gates on
enrollment, mints a stable token, writes the cache and sync_surface_artifacts,
debounces catalog changes, unpublishes on withdrawal, attributes fetches, and
emits sync.surface_artifact.updated (which makes the control plane re-evaluate).
If the surface’s attributes or scoring rules differ from core, tag them with the
surface id in the catalogue (AttributeField.Surface, ScoringRule.Surface);
the sync plane already scopes resolve/score to the store’s surfaces.
4. Console — web/console/src/surfaces/<surface_id>/locale.en.ts
The folder name equals the surface id; it exports the surface’s words, merged
under surfaces.<id>. Needed keys: name, description, and whatever the
descriptor references (prereq.*, remediation.*, config.<field>.label|help,
step.*, artifact.*), plus check.<check_id> (pre-enrollment preview),
artifactDetail.<key> and signal.<signal>. A missing key degrades to a
humanised id, never a raw dotted key. The page, store, enrollment flow and
config form are generic. SurfacesSurface.test.ts lists the keys of the demo
example_marketplace surface; add the same for yours.
For the standalone (mock) console, add the surface to src/api/surfaceMock.ts;
the example_marketplace entry there is a complete worked example (artifact,
prerequisites, config fields, external steps, verification signal) that exists
only in mock mode.
5. Check it
- control: add an end-to-end test next to
surface/extensibility_test.go, which enrolls a fixture surface end to end against the public registry API (enroll → config → artifact → submit → signal → enrolled; tenant fence; direct_agent undisturbed) - sync: a generator test (deterministic output, config handling)
go test ./internal/archtest/ ./internal/planes/...andcd web/console && npm run typecheck && npm run test:run- If the surface changes the OpenAPI (new descriptor field),
npm run gen:api.