Skip to navigation

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 and 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.

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.

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
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?.
  • 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.

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 }.

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.

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
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.

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.

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
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
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
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.

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
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: