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:
The definition has the following fields:
name: the Surface name. It identifies the Surface within the Product.type: the Surface type, such aschat,api,mcp,webhook,slack,email, ortelegram. For every type, see What are Surfaces?.behavior: the Surface settings for its type.ensureadds thetypefield tobehaviorfor you.inboundandoutbound: channel connection settings and credentials, such as a Slack bot token.status:draft,active, orpaused.
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:
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:
- Call
Runtype.surfaces.pull('YOUR_PRODUCT_ID', 'Support Chat'). - Commit the returned
definitionto your repository. Replace the credential values ininboundandoutboundwith environment variable reads before you commit. - Run
ensure. Because the content matches,ensurereturnsunchangedand 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:
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:
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:
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 nextensurereturnsunchangedand 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:
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.
- Send
POST /v1/products/YOUR_PRODUCT_ID/surfaces/ensurewithnameand the fulldefinition, and omitcontentHash. The response includesresultand thecontentHashthat the server computed. Store thatcontentHash. - On later runs, send a hash-only request with
nameand the storedcontentHash:{ "name": "Support Chat", "contentHash": "STORED_HASH" }. ReplaceSTORED_HASHwith the value from step 1. A match returns{ "result": "unchanged" }. A miss returns{ "result": "definitionRequired" }, and you repeat step 1. - Handle status code
409forexternal_modificationandremote_changedthe same way as the SDK conflicts. Status code422withcontent_hash_mismatchmeans you sent bothdefinitionand acontentHashthat 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: create the Product and manage the whole Product as code
- What are Surfaces?: compare the Surface types and their settings
- Product versioning and status: choose the right Surface status
- Manage Agents as code: apply the Agents behind your Capabilities from your repository
- Manage tools as code: apply Tool definitions from your repository