Manage Agents as code

Keep your Agent definitions in your repository. Review and version them with your code, then converge them at deploy time. The dashboard displays the definition that your deployment writes.

Why manage Agents as code?

When you edit an Agent in the dashboard, its system prompt and configuration live outside your codebase. You cannot review those changes with your code, and definitions can drift across environments. Managing Agents as code gives CI a repeatable update path: CI converges the definition, and the dashboard displays the deployed result.

The SDK method ensure is a convergence operation, not a save operation. It makes the platform definition match the object you provide and does nothing when they already match. After the definition matches, repeating the same call makes no content changes.

Define the Agent

defineAgent is a local constructor that does not make network requests. It validates the definition shape and returns the canonical definition object. To keep the definition portable across environments, use builtin:, platform:, or mcp: references, or reference a saved tool with tool:<name>. The constructor rejects account-scoped IDs that start with tool_. For more information, see Reference saved tools by name.

The following example defines a pricing assistant:

import { defineAgent } from '@runtypelabs/sdk'
export const pricingAssistant = defineAgent({
name: 'Pricing Assistant',
description: 'Marketing pricing page chat assistant.',
icon: '💬',
model: 'claude-sonnet-4-6',
temperature: 0.7,
loopConfig: { maxTurns: 1 },
systemPrompt: 'Help customers compare plans.',
})

To bootstrap a definition from an Agent that you built in the dashboard, open the Agent editor. Press ⌘K on macOS or Ctrl+K on other platforms to open the command palette, then run Get code. The dialog includes your current configuration, including unsaved edits, and generates a defineAgent block with agents.ensure and drift-check examples.

If the Agent references a saved tool by an account-scoped ID that starts with tool_, the generated code marks each reference with a comment. Replace each one with a portable reference or a tool:<name> reference before you converge. Get code supports standard Runtype Agents, not external A2A (Agent-to-Agent) or Claude-managed Agents. The flow editor provides the same command for flows.

An Agent’s identity is its name and your account scope, which is organization or personal. The API endpoint and credentials determine the environment. Point the client at staging and use staging credentials to converge staging. Point it at production and use production credentials to converge production. You do not need per-environment ID files or a state file.

Renaming a definition does not rename the Agent. ensure creates an Agent under that name and leaves the old Agent in place because it never deletes. Treat the name as the stable identity.

Reference saved tools by name

Built-in, platform, and Model Context Protocol (MCP) tools have portable IDs that you can hardcode. Saved tools receive a per-account, per-environment ID with the tool_ prefix. This includes external HTTP tools, custom code tools, and flow tools that you build in the dashboard. Do not include those IDs in a portable definition. Reference a saved tool by its exact name with tool:<name>:

export const supportAgent = defineAgent({
name: 'Support Agent',
model: 'claude-sonnet-4-6',
tools: {
toolIds: ['builtin:web_search', 'tool:My Scraper'],
toolConfigs: { 'tool:My Scraper': { depth: 2 } },
approval: {
require: ['tool:My Scraper'],
choices: { alwaysAllow: true },
},
},
})

Set approval.choices.alwaysAllow to true to show an Always allow button in the approval prompt. Runtype stores a persistent grant when an end user selects it, so future dispatches for that Agent skip the prompt for that tool.

View or revoke grants in the Agent editor under Safety, or use GET /v1/tool-approval-grants and DELETE /v1/tool-approval-grants/{id}. For more information, see Tool approval grants.

tool:<name> uses the tool’s exact, case-sensitive name, including spaces or colons. The name is the canonical reference used for hashing. The same name and definition produce the same content hash in each environment. Runtype resolves the name to the saved tool at runtime on every dispatch.

Converge the tool before or after the Agent. You can recreate the tool without running ensure again because the reference resolves at runtime.

Use ensure, including dryRun, to validate each named reference during convergence:

  • A missing name: ensure fails with an error. Create the tool before you run the agent deployment step so the missing dependency appears in CI.
  • A duplicate name: ensure fails and identifies both tools. Rename one tool before you converge the Agent.
  • A duplicate created after a successful converge: dispatch continues using the older tool and logs a warning instead of failing a live conversation.

Use tool:<name> anywhere you reference the tool. Use it in toolIds, the keys of toolConfigs and perToolLimits, approval.require, and the subagentConfig and codeModeConfig tool pools. The ensure surface checks that the referenced tool exists when the name appears in toolIds, subagentConfig.toolPool, or codeModeConfig.toolPool. It treats approval.require references as runtime name patterns, so it accepts wildcards such as mcp:* without checking existence at converge time. Raw IDs that start with tool_ remain rejected.

When you pull an Agent built in the dashboard, pull reverse-maps saved-tool IDs to tool:<name>. You can paste the returned definition into your code. If pull cannot safely emit a name, it leaves the raw ID and adds a warnings entry. This happens when the tool was deleted or its name is shadowed by an older same-named tool. The warning explains the fix.

Migrating an existing Agent from IDs to names changes its stored configuration, so the first ensure after the migration returns updated.

Saved subagents and flow tools

When an Agent delegates to a saved subagent or calls a saved flow as a tool, reference those resources by name:

  • agent:<name>: the saved subagent’s config.agentId.
  • flow:<name>: the flow-as-tool runtime tool’s config.flowId.

The following example references a saved subagent and a saved flow:

export const routingAgent = defineAgent({
name: 'Routing Agent',
model: 'claude-sonnet-4-6',
tools: {
runtimeTools: [
{
toolType: 'subagent',
name: 'escalate',
description: 'Escalate to a specialist',
parametersSchema: {},
config: { agentId: 'agent:Billing Specialist' },
},
{
toolType: 'flow',
name: 'lookup',
description: 'Look up an order',
parametersSchema: {},
config: { flowId: 'flow:Order Lookup' },
},
],
},
})

Raw IDs with the agent_ or flow_ prefix are rejected on the ensure surface. ensure checks that each referenced Agent or Flow exists and has one matching name at converge time. pull maps those IDs to the name forms. An inline subagent or inline flow embeds its definition, so it needs no reference.

Converge at deploy time

To converge the definition, configure the SDK with an API key and call Runtype.agents.ensure:

import { Runtype } from '@runtypelabs/sdk'
Runtype.configure({ apiKey: process.env.RUNTYPE_API_KEY })
const result = await Runtype.agents.ensure(pricingAssistant)

The result includes result, agentId, versionId, and contentHash. The result value is unchanged, created, or updated.

ensure starts with a hash-only probe. When the definition matches, the SDK writes nothing. When it differs, ensure writes the live Agent configuration and appends an immutable version snapshot. A dashboard edit that you overwrite remains recoverable in version history.

The server computes contentHash from the canonical, normalized definition. The SDK uses that hash for later probes. Formatting differences in your local object do not cause repeated updates.

Deploying the converged version

By default, ensure writes the saved configuration and a draft version, and moves no release alias. To deploy the version that ensure creates, name the alias to activate:

await Runtype.agents.ensure(pricingAssistant, { deploy: { alias: 'live' } })

deploy is the canonical input. release: 'publish' is the compatibility spelling of deploy: { alias: 'live' } and release: 'none' of omitting both, and they keep working. Sending release and deploy in the same call is refused with 400.

The response carries a deployment object describing what the pointer now selects: the alias, its versionId and versionNumber, the alias revision to quote back on a later compare-and-swap, the receiptId of the deployment receipt this converge appended, and changed, which is false when the pointer already selected this version.

Labeling the version

Pass an optional version object to stamp provenance on the version row that ensure appends, so version history answers which commit produced it:

await Runtype.agents.ensure(pricingAssistant, {
version: { label: process.env.GIT_SHA, notes: 'Promoted from CI run 4821.' },
})

Both fields are optional. label holds up to 100 characters (typically a git SHA or a release tag) and notes up to 2000. Omit version to keep the default sdk-ensure label and the server-generated notes.

Deploy a preview

Name any alias other than live and the converge becomes a preview deployment: it saves a candidate version and aims that pointer at it, and it touches nothing else. The saved configuration, the content hash, the capabilities, and the draft pointer all stay where they were, so live keeps serving what it served before.

const result = await Runtype.agents.ensure(pricingAssistant, {
deploy: { alias: `pr-${process.env.PR_NUMBER}` },
version: { label: process.env.GIT_SHA },
})
console.log(result.deployment?.alias, result.deployment?.versionId)

The same converge from the CLI:

runtype agents ensure pricing-assistant.json --deploy pr-482 --label "$GITHUB_SHA"

Things to know before you wire this into a pipeline:

  • A preview converge decides “unchanged” against the preview. It compares the definition with the version this alias was last deployed from, not with the live configuration, so a preview that already matches reports unchanged and moves nothing.
  • onConflict and expectedRemoteHash are refused with 400 on a preview deploy. Both describe writes to the live configuration, and a preview deploy performs none. Drop them, or deploy to live.
  • An unchanged converge still renews the preview. The 14-day expiry restarts on every successful converge, including one that changed nothing.
  • A preview deploy of an unknown Agent creates the Agent. That Agent is created undeployed: the preview alias points at the candidate and live is not aimed at anything.
  • Aliases are an organization feature. A personal-account credential gets 400 with code: "alias_requires_organization".
  • Preview quotas apply. Past 50 active previews in the organization or 10 on one Agent, the converge answers 429 with code: "PREVIEW_ALIAS_LIMIT".

To make a preview’s tools call that pull request’s own environment while the version content stays identical to what live runs, pass deploy.bindings. See Per-alias secret bindings.

A pull-request workflow

Two jobs give a pull request its own running Agent and clean up after itself. The first converges a preview and grades it; the second archives the pointer when the pull request closes.

The grading step assumes the repository already holds at least one *.eval.ts suite targeting this Agent, because runtype eval run discovers suites from those files and --alias only chooses which version they run against. Write one first, following Manage evals as code, or drop that step.

.github/workflows/agent-preview.yml
name: Agent preview
on:
pull_request:
types: [opened, synchronize, closed]
jobs:
preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
env:
RUNTYPE_API_KEY: ${{ secrets.RUNTYPE_PREVIEW_API_KEY }}
PREVIEW_ALIAS: pr-${{ github.event.number }}
steps:
- uses: actions/checkout@v4
- run: npm install -g @runtypelabs/cli
- run: |
runtype agents ensure agents/pricing-assistant.json \
--deploy "$PREVIEW_ALIAS" \
--label "${{ github.sha }}"
- run: runtype eval run --alias "$PREVIEW_ALIAS"
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
env:
RUNTYPE_API_KEY: ${{ secrets.RUNTYPE_PREVIEW_API_KEY }}
steps:
- run: npm install -g @runtypelabs/cli
- run: runtype agents aliases archive "pr-${{ github.event.number }}" --all

Give the workflow’s credential the AGENTS:DEPLOY:PREVIEW scope and not AGENTS:DEPLOY:LIVE. A preview pipeline that cannot move live is the guarantee, not a policy the workflow chooses to honor.

agents aliases archive ALIAS --all archives that alias name on every Agent in the organization, which is what a closed pull request wants when one branch converged several Agents. Add --dry-run to see what it would archive first. To archive one pointer on one Agent, pass both: runtype agents aliases archive AGENT_ID ALIAS.

Archiving is not a delete. The alias keeps its deployment history, executions already admitted run to completion, and the memory the preview wrote stays where it is. See the namespace ladder for keeping preview memory out of production in the first place.

Per-environment values

Keep the definition byte-identical across environments and let the values vary underneath it:

  • Reference credentials and environment-specific endpoints as {{secret:NAME}} and resolve them from the organization’s secrets. The definition then carries the name, not the value, and its content hash is the same everywhere.
  • Reference saved tools as tool:NAME, saved Agents as agent:NAME, and Flows as flow:NAME, so each organization resolves its own row at run time.
  • When one preview genuinely needs a different value for the same name, bind it to the alias rather than editing the definition. See Per-alias secret bindings.

Rendering a different value into the definition itself, such as an inline URL or a different model, produces a different artifact. An evaluation of the staging artifact says nothing about the production one, so do not carry an eval claim across a render.

Promote across organizations

Hard isolation between environments is the organization boundary: separate organizations, separate credentials, separate data. To move a definition from one to the other, use the four-step promotion recipe. Each step takes an explicit source and target credential, read from environment variables so a key never appears in a command line or a CI log.

export RUNTYPE_SOURCE_API_KEY=YOUR_SOURCE_API_KEY
export RUNTYPE_TARGET_API_KEY=YOUR_TARGET_API_KEY
runtype agents promote prepare "Pricing Assistant" --alias candidate --commit "$GITHUB_SHA"
runtype agents promote validate promotion.json
runtype agents promote evaluate promotion.json --suite YOUR_SUITE_ID
runtype agents promote activate promotion.json --alias live --reason "Release 2026.09"

Replace the following:

  • YOUR_SOURCE_API_KEY: an API key for the organization the definition comes from, such as staging
  • YOUR_TARGET_API_KEY: an API key for the organization you are deploying into, such as production
  • YOUR_SUITE_ID: an eval suite in the target organization
  • Pricing Assistant: the Agent name, which is the ensure identity in both organizations

What each step does:

  • prepare pulls the definition from the source organization and stages it at a preview alias in the target. The target’s live is untouched. It writes promotion.json, which records the Agent name, the source Agent id, version id, content hash and commit, and the target Agent id, staged version id, alias, and alias revision. Pass --manifest to write it somewhere else.
  • validate re-plans the staged candidate in the target organization and reports every named reference it could not resolve there, such as a tool:NAME that exists in staging and not in production. It exits non-zero when any reference is unresolved.
  • evaluate runs an eval suite in the target organization pinned to the exact staged version. It reports the result. Nothing gates activation on it, so read the result and decide.
  • activate deploys that exact staged version id at the target pointer. It reads the destination alias first and quotes that alias’s current revision as If-Match, not the staged preview’s revision from the manifest, then links the source ids, hashes, and commit on the deployment receipt. --alias defaults to live. --idempotency-key defaults to one derived from the manifest, so a retried job replays rather than deploying twice.

Credentials never cross the boundary. Nothing in the manifest is a secret value, and the target organization’s own credentials decide what the activation is allowed to do. The manifest is provenance, not authority.

Limitations

State these before you build a release process on top of the recipe:

  • Flows have no aliases. Only Agents have the version pointer plane. An Agent that calls a Flow by name resolves whichever Flow answers to that name at run time.
  • external and Claude Managed Agents accept no version selector. They answer 422 with code: "selector_unsupported_agent_type", and they keep running their saved configuration.
  • Personal-account Agents have no aliases. Deploying one is refused with 400 and code: "alias_requires_organization".
  • Previews are configuration previews, not sandboxes. A preview shares the organization’s connections, Records, and secrets with live. Per-alias bindings override individual secret values; they isolate nothing else.
  • A version pins the Agent’s own definition, not its dependencies. Sub-Agents, Flows, saved tools, MCP servers, Skills, and secrets referenced by name resolve at run time. A deployment receipt records their fingerprints as they stood at activation, and a matching fingerprint proves that a definition is unchanged, never that its behavior is.
  • A sub-Agent resolves through its own live alias. A parent version does not pin the versions of the Agents it delegates to, whether they are reached through a capability, a Skill, or a nested reference.
  • Rollback restores configuration only. It does not undo external side effects, data already written, or edits made since to the resources the version references by name.

Detect drift in CI

A dry run sends the full definition without persisting changes. It returns a plan that reports whether the definition would change and which keys would change:

const plan = await Runtype.agents.ensure(pricingAssistant, { dryRun: true })

The plan includes result: 'plan', a changes value of none, create, or update, changedKeys, contentHash, and an optional remoteHash.

To make a drift check fail when the plan is not none, pass expectNoChanges: true:

await Runtype.agents.ensure(pricingAssistant, { expectNoChanges: true })

The SDK throws AgentDriftError when the plan reports create or update.

To bind an apply step to the exact state that the dry run inspected, pass its remoteHash as expectedRemoteHash:

await Runtype.agents.ensure(pricingAssistant, { expectedRemoteHash: plan.remoteHash })

Conflicts with dashboard edits

The platform records the source of each Agent write. An external edit in the dashboard, API, or MCP changes that source. If the edit changes an Agent managed by ensure, the API returns status code 409 by default. The SDK exposes this response as an AgentEnsureConflictError with code: 'external_modification'.

The dashboard shows this state. When you open an Agent managed by ensure, the dashboard shows a Managed in code badge. The badge appears in the Agent editor header, the edit slide-over, and next to the Agent’s name on the Agents list page. To find every Agent managed by code, open Agents and filter by Managed in code. Clicking Save, or publishing a version from Version History, opens a confirmation dialog. Click Save anyway to continue.

Saving changes the provenance from sdk, so the next ensure detects the drift. By default, ensure fails with a conflict. Pass onConflict: 'overwrite' to overwrite the dashboard edit. Update the Agent definition in code to make a lasting change.

You can resolve the conflict in either of these ways:

  • Repository wins: pass onConflict: 'overwrite'. The live Agent configuration uses the repository definition, and the dashboard edit remains in version history. In CI, pair this option with a dry-run gate to surface drift.
  • Dashboard wins: run pull, review the returned definition as a git diff, then commit or revert the change.

To pull the definition by name, call Runtype.agents.pull:

const { definition, contentHash, lastModifiedSource, updatedAt } =
await Runtype.agents.pull('Pricing Assistant')

What ensure does and does not manage

ensure converges the Agent definition: its name, description, icon, and runtime configuration. This includes the model, system prompt, sampling, tool configuration, loop settings, and error handling. It does not manage related state such as capabilities, skill bindings, memory, conversations, or executions. It never deletes or renames Agents. ensure applies only to standard Runtype Agents, not external A2A (Agent-to-Agent) or Claude-managed Agents.

Call the API directly

If you call the API directly, use POST /v1/agents/ensure. The protocol has three steps:

  1. Send a hash-only request with name and contentHash. A matching hash returns { "result": "unchanged" }. A nonmatching hash returns HTTP status code 200 with { "result": "definitionRequired" }. Resend the request with the full definition.
  2. For a full request, let the server recompute the canonical hash. The response includes contentHash; use it in later probes.
  3. Handle conflicts as follows. The API returns status code 409 for external_modification or remote_changed. If the submitted contentHash does not match the server’s hash for the definition, the API returns 422 with content_hash_mismatch. Omit contentHash on full requests to avoid this mismatch.

Every full request also accepts an optional version object, { "label": "a1b2c3d", "notes": "Promoted from CI run 4821." }, recorded on the version row that the request appends. Both fields are optional; label holds up to 100 characters and notes up to 2000. The server trims both, and treats a blank value as omitted. Omitting version keeps the default sdk-ensure label and the server-generated notes. A typical label is a git SHA or a release tag.

A full request also accepts deploy, { "alias": "pr-482" }, which activates the version this converge appends at that release alias and returns a deployment object describing the result. release remains accepted as the compatibility spelling; sending both returns 400.

To pull an Agent definition directly, send GET /v1/agents/pull with the name query parameter. The API returns the canonical definition and these fields: contentHash, lastModifiedSource, updatedAt, and versionId.

Next steps

Continue with these guides: