> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.runtype.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.runtype.com/_mcp/server.

# Manage surfaces as code

Keep your Surface definitions in your repository, review them with your code, and apply them at deploy time with `ensure`. `ensure` compares your definition with the saved Surface in a Product and creates or updates the Surface only when they differ. For related guides, see [Manage Agents as code](/user-guide/agents/manage-agents-as-code) and [Manage tools as code](/user-guide/tools/manage-tools-as-code).

## Before you begin

Create the Product that the Surface belongs to, and copy its Product ID. You can create the Product in the dashboard, with the API, or as code. For more information, see [Creating a product](/user-guide/products-surfaces/creating-a-product).

To run `ensure`, including a dry run, your API key needs the `PRODUCTS:SURFACES:WRITE`, `PRODUCTS:SURFACES:*`, or `PRODUCTS:*` permission. To run `pull`, it needs `PRODUCTS:SURFACES:READ`, `PRODUCTS:READ`, `PRODUCTS:SURFACES:*`, or `PRODUCTS:*`. Without the required permission, the API returns status code `403`. To change a key's permissions, see [Managing API keys](/user-guide/settings/managing-api-keys).

## Define the surface

`defineSurface` is a local constructor that doesn't make network requests. It checks the top-level definition shape, rejects unknown fields, and returns a definition object with the Surface's `name`, `type`, `behavior`, `inbound`, `outbound`, and `status` fields.

Use the following code to define a chat Surface:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { defineSurface } from '@runtypelabs/sdk'

export const supportChat = defineSurface({
  name: 'Support Chat',
  type: 'chat',
  behavior: {
    welcomeMessage: 'Hi! How can I help with your order?',
    allowedOrigins: ['https://example.com'],
  },
  status: 'active',
})
```

The definition has the following fields:

* `name`: the Surface name. It identifies the Surface within the Product.
* `type`: the Surface type, such as `chat`, `api`, `mcp`, `webhook`, `slack`, `email`, or `telegram`. For every type, see [What are Surfaces?](/user-guide/products-surfaces/what-are-surfaces).
* `behavior`: the Surface settings for its type. `ensure` adds the `type` field to `behavior` for you.
* `inbound` and `outbound`: channel connection settings and credentials, such as a Slack bot token.
* `status`: `draft`, `active`, or `paused`.

Always set `status`. If you omit it, `ensure` saves the Surface as `draft`, and most Surface types reject requests in **Draft** status. For more information, see [Product versioning and status](/user-guide/products-surfaces/product-versioning-and-status).

Put credentials in `inbound` or `outbound`, not in `behavior`. `ensure` removes credential fields, such as `botToken` or `signingSecret`, from `behavior` before it saves the Surface. If you omit `inbound` or `outbound`, `ensure` keeps the stored values.

Don't commit credentials to your repository. Read them from environment variables when you build the definition, for example `outbound: { botToken: process.env.SLACK_BOT_TOKEN }`.

> **Warning**
>
> `ensure` doesn't rotate credentials. The content hash leaves out `inbound` and `outbound`, so a
> change that only touches those fields returns `unchanged`, and the Surface keeps its stored
> credentials. To rotate a credential, update it in the Surface settings in the dashboard or with
> the Product API. Because the rest of the Surface still matches your definition, the next `ensure`
> returns `unchanged` and doesn't report a conflict.

> **Note**
>
> `ensure` matches a Surface by its name within the Product. If you rename a definition, `ensure`
> creates a new Surface and leaves the old one in place, because `ensure` never deletes or renames
> Surfaces. If several Surfaces in the Product share a name, `ensure` and `pull` act on the oldest
> one.

## Apply the definition at deploy time

Configure the SDK and apply the Surface definition to a Product with the following code:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { Runtype } from '@runtypelabs/sdk'

Runtype.configure({ apiKey: process.env.RUNTYPE_API_KEY })

const result = await Runtype.surfaces.ensure('YOUR_PRODUCT_ID', supportChat)
```

Replace `YOUR_PRODUCT_ID` with the ID of the Product that owns the Surface.

The response includes `result`, `surfaceId`, and `contentHash`. The `result` value is `unchanged`, `created`, or `updated`. When the saved Surface already matches and is managed in code, `ensure` sends only a content hash and writes nothing. Otherwise it sends the full definition and creates or updates the Surface, unless the Surface was edited outside code. For that case, see [Resolve conflicts with dashboard edits](#resolve-conflicts-with-dashboard-edits).

Surfaces don't have versions, so there's no draft or publish step. An update takes effect right away. To stage a change, apply it in a separate environment or account first.

`ensure` doesn't change which Capabilities the Surface exposes. Connect Capabilities in the Product editor, with the Product API, or with `Runtype.products.ensureFpo`. For more information, see [Manage a whole Product as code](/user-guide/products-surfaces/creating-a-product#manage-a-whole-product-as-code).

## Adopt an existing Surface

To bring a Surface that you created in the dashboard under code management, pull its definition first:

1. Call `Runtype.surfaces.pull('YOUR_PRODUCT_ID', 'Support Chat')`.
2. Commit the returned `definition` to your repository. Replace the credential values in `inbound` and `outbound` with environment variable reads before you commit.
3. Run `ensure`. Because the content matches, `ensure` returns `unchanged` and marks the Surface as managed in code.

If you skip the pull and your definition differs from a Surface that was last saved outside code, the first `ensure` fails with a conflict.

## Detect drift in CI

A dry run sends the full definition, writes nothing, and returns a plan. Run a dry run with the following code:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
const plan = await Runtype.surfaces.ensure('YOUR_PRODUCT_ID', supportChat, { dryRun: true })
```

The plan includes `result: 'plan'`, a `changes` value of `none`, `create`, or `update`, `changedKeys`, `contentHash`, and an optional `remoteHash` and `surfaceId`. The `changedKeys` array names the changed fields, such as `type`, `status`, or `behavior.welcomeMessage` for a changed key within `behavior`.

To fail a CI check when the plan isn't `none`, pass `expectNoChanges: true`. This option runs a dry run and never writes:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
import { SurfaceDriftError } from '@runtypelabs/sdk'

try {
  await Runtype.surfaces.ensure('YOUR_PRODUCT_ID', supportChat, { expectNoChanges: true })
} catch (err) {
  if (err instanceof SurfaceDriftError) console.error(err.plan.changedKeys)
  throw err
}
```

A dry run compares content only, so it never reports an `external_modification` conflict. A Surface edited in the dashboard shows as `update`, the same as a change in your code. To tell the two apart, call `pull` and check `lastModifiedSource`. The values `sdk` and `terraform` mean the Surface is managed in code. A value such as `dashboard`, `api`, or `mcp` means someone edited it outside code, and a later apply fails with a conflict.

## Apply a reviewed plan

To make sure the apply step writes over the same state that the dry run inspected, pass the plan's `remoteHash` as `expectedRemoteHash`:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
await Runtype.surfaces.ensure('YOUR_PRODUCT_ID', supportChat, {
  expectedRemoteHash: plan.remoteHash,
})
```

If the saved Surface changed after the dry run, the SDK throws a `SurfaceEnsureConflictError` with `code: 'remote_changed'`. Run the dry run again and review its plan.

This guard works only when the plan has a `remoteHash`, which means the Surface already existed at dry-run time. A plan with `changes: 'create'` has no `remoteHash`, so the SDK sends no `expectedRemoteHash`. If another writer creates a Surface with the same name between the dry run and the apply, the apply can update that Surface without a `remote_changed` conflict. When the create plan matters, run the dry run again right before the apply, and serialize deploys that write the same Product.

## Resolve conflicts with dashboard edits

Runtype records where each Surface write came from. Writes from `ensure` (`sdk`) and from the Terraform provider (`terraform`) count as managed in code and don't conflict with each other.

When an edit outside code, from the dashboard, the API, or the MCP server, changes a managed Surface so that it no longer matches your definition, the next `ensure` returns status code `409`. The SDK throws a `SurfaceEnsureConflictError` with `code: 'external_modification'`. Its `lastModifiedSource` and `modifiedAt` fields identify the edit. If the edit left the Surface identical to your definition, `ensure` returns `unchanged` instead.

Choose one of these resolutions:

* **Keep the dashboard edit:** Call `pull`, review the returned definition as a git diff, and commit it. The next `ensure` returns `unchanged` and marks the Surface as managed in code.
* **Keep the repository definition:** Pass `onConflict: 'overwrite'` to replace the dashboard edit.

> **Warning**
>
> An overwrite can't be undone. Surfaces keep no version history, so the dashboard edit is lost. If
> you might need it, run `pull` and save the output before you overwrite.

Pull the saved definition with the following code:

**`TypeScript SDK`**

```typescript title="TypeScript SDK"
const { surfaceId, definition, contentHash, lastModifiedSource, updatedAt } =
  await Runtype.surfaces.pull('YOUR_PRODUCT_ID', 'Support Chat')
```

The pulled `definition` includes the stored `inbound` and `outbound` values, including credentials in plain text. Treat the output as a secret. If no Surface in the Product has that name, `pull` fails with status code `404`.

## Call the API directly

If you don't use the TypeScript SDK, send the same requests yourself. Authenticate each request with `Authorization: Bearer YOUR_API_KEY`, where `YOUR_API_KEY` is your Runtype API key.

1. Send `POST /v1/products/YOUR_PRODUCT_ID/surfaces/ensure` with `name` and the full `definition`, and omit `contentHash`. The response includes `result` and the `contentHash` that the server computed. Store that `contentHash`.
2. On later runs, send a hash-only request with `name` and the stored `contentHash`: `{ "name": "Support Chat", "contentHash": "STORED_HASH" }`. Replace `STORED_HASH` with the value from step 1. A match returns `{ "result": "unchanged" }`. A miss returns `{ "result": "definitionRequired" }`, and you repeat step 1.
3. Handle status code `409` for `external_modification` and `remote_changed` the same way as the SDK conflicts. Status code `422` with `content_hash_mismatch` means you sent both `definition` and a `contentHash` that doesn't match it.

The request body also accepts `dryRun`, `onConflict`, and `expectedRemoteHash`. The `definition.name` value must match the top-level `name`. Otherwise the API returns status code `400`. If the Product doesn't exist in your account, the API returns status code `404`.

To pull a definition, send `GET /v1/products/YOUR_PRODUCT_ID/surfaces/pull` with the `name` query parameter. The response includes `surfaceId`, `definition`, `contentHash`, `lastModifiedSource`, and `updatedAt`.

## Next steps

Continue with these guides:

* [Creating a product](/user-guide/products-surfaces/creating-a-product): create the Product and manage the whole Product as code
* [What are Surfaces?](/user-guide/products-surfaces/what-are-surfaces): compare the Surface types and their settings
* [Product versioning and status](/user-guide/products-surfaces/product-versioning-and-status): choose the right Surface status
* [Manage Agents as code](/user-guide/agents/manage-agents-as-code): apply the Agents behind your Capabilities from your repository
* [Manage tools as code](/user-guide/tools/manage-tools-as-code): apply Tool definitions from your repository