---
title: "Add an agent surface"
description: "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](https://github.com/sarvexlab/sarvex) — 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.PrerequisiteCheck`s, 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/...` and `cd web/console && npm run typecheck && npm run test:run`
- If the surface changes the OpenAPI (new descriptor field), `npm run gen:api`.
