> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runflow.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent Config

> Declare the settings of an agent in code and let your team change them per environment in the portal, without a deploy

Most agents have values that change more often than the code: a tone, a follow-up delay, a list of
topics that go to a human, the units a single agent serves. **Agent Config** lets you declare those
values once, in code, with types and defaults, and gives the portal a form to edit them per
environment. Saving takes effect on the **next execution**. No deploy, no redeploy.

![Configuration tab with a list of hotels on the left and the selected hotel's form on the right](https://mintcdn.com/runflowai/omhuHUWlUZuaGpxb/images/agent-config/workbench.jpg?fit=max\&auto=format\&n=omhuHUWlUZuaGpxb\&q=85\&s=8d50cc9d7acfe02b87c6696fb6240e2d)

The Configuration tab of an agent that serves several hotels

> [!NOTE]
>
> Agent Config is rolled out per workspace. If an agent declares a manifest but you don't see the
> **Configuration** tab, ask Runflow to enable it for your workspace. The code side works either way:
> the defaults you declare are always delivered to the agent.

## How it works

You describe the config with a [Zod](https://zod.dev) schema in `manifest/index.ts`. When you deploy,
the CLI turns it into `agent.manifest.json`, the platform builds a form from it, and every execution
receives the resolved values. Inside the agent you read them with `config()`.

Values come from three layers. The higher layer wins, field by field:

| Layer | Where it lives | Who changes it |
| - | - | - |
| **Portal** | Saved in the Configuration tab, per environment, with history | Operations team, no deploy |
| **Repository** | `manifest/staging.json` and `manifest/production.json` | Developers, through a deploy |
| **Code** | `.default(...)` in the schema | Developers, through a deploy |

A field nobody touched shows its default in the portal. A field changed in the portal is marked
as changed and can be restored to the default with one click.

## Quick start

**1. Declare the schema**

Create `manifest/index.ts`. The default export is `defineManifest({ config })`.

```ts manifest/index.ts theme={null}
import { z } from 'zod/v4';
import { defineManifest } from '@runflow-ai/sdk';

export const configSchema = z.object({
  tone: z
    .enum(['formal', 'casual'])
    .default('formal')
    .meta({ title: 'Tone', ui: { labels: { formal: 'Formal', casual: 'Casual' } } }),
  followUpHours: z
    .number().int().min(1).max(72)
    .default(24)
    .meta({ title: 'Follow-up after', unit: 'h' }),
  handoffTopics: z
    .array(z.string())
    .default(['refund', 'complaint'])
    .meta({ title: 'Topics that go to a human' }),
});

export default defineManifest({ config: configSchema });
```

**2. Read it in the agent**

Import the manifest once in your entry file, then call `config()` wherever you need the values.

```ts main.ts theme={null}
import { Agent, openai, config } from '@runflow-ai/sdk';
import type { z } from 'zod/v4';
import { configSchema } from './manifest';

type Config = z.infer<typeof configSchema>;

export async function main(input) {
  const cfg = config<Config>();

  const agent = new Agent({
    name: 'Support',
    model: openai('gpt-4o'),
    instructions: [
      `Answer in a ${cfg.tone} tone.`,
      `Hand off to a human when the topic is: ${cfg.handoffTopics.join(', ')}.`,
    ].join('\n'),
  });

  return agent.process(input);
}
```

> [!WARNING]
>
> Keep the `import ... from './manifest'`. It registers the schema, so `config()` validates the
> values and applies Zod transforms. Without it you still get the values, but untyped and
> unvalidated.

**3. Deploy**

```bash theme={null}
rf agents deploy
```

The CLI generates `agent.manifest.json` from `manifest/` and ships it with the code. If the schema
or a JSON file in `manifest/` is invalid, the deploy stops before uploading anything.

**4. Edit in the portal**

Open the agent, go to **Configuration**, change a value and click **Save in staging**. The next
staging execution already sees it. When it looks right, click **Promote to production**.

## From schema to form

The portal renders each Zod type with a matching control. You don't write any UI.

| Zod | Control in the portal |
| - | - |
| `z.string()` | Text input. Add `ui: { widget: 'textarea' }` for long text |
| `z.number()`, `.int()` | Number input with `min` / `max` checks and an optional `unit` |
| `z.boolean()` | Switch |
| `z.enum([...])` | Select, with display names from `ui.labels` |
| `z.array(z.string())` | Chips: type and press Enter |
| `z.object({...})` | A titled section with its own fields |
| `z.record(z.string(), z.object({...}))` | A **collection**: a list of items you can add, duplicate, rename and remove |
| `.optional()` / `.default(x)` | Not required / pre-filled with `x` |
| `.min(1)` on a string | Required, and the portal blocks the save until it's filled |

Everything you put in `.meta({...})` shapes how the field is shown:

| Key | What it does |
| - | - |
| `title` | Field label |
| `description` | Help text under the field |
| `unit` | Suffix next to number inputs, such as `h` or `min` |
| `visibleWhen: { field, equals }` | Show the field only when a sibling field has that value |
| `ui.group` | Puts top-level fields in a named section of the side rail |
| `ui.order` | Position of the field inside its section |
| `ui.widget: 'textarea'` | Multi-line text |
| `ui.placeholder` | Placeholder text |
| `ui.labels` | Display names for enum values |
| `ui.hidden` | Keep the field in code but out of the form |
| `ui.itemLabel` | Collections: the item field used as its name in the list |
| `ui.itemName` | Collections: what one item is called, such as `hotel` or `store` |
| `ui.itemSubtitle` | Collections: an item field shown next to the name, such as a code. Accepts a dotted path |

```ts Conditional field theme={null}
parking: z.boolean().default(false).meta({ title: 'Parking' }),
parkingNotes: z
  .string()
  .optional()
  .meta({ title: 'Parking notes', visibleWhen: { field: 'parking', equals: true } }),
```

## Collections: one agent, many units

When one agent serves several stores, hotels, clinics or brands, declare a **collection** with
`z.record`. Each item gets its own form, and the team adds a unit in the portal without touching code.

```ts manifest/index.ts theme={null}
const store = z.object({
  name: z.string().min(1).meta({ title: 'Name' }),
  channelIds: z.array(z.string()).meta({ title: 'WhatsApp numbers' }),
  openingHours: z.string().default('09:00-18:00').meta({ title: 'Opening hours' }),
  erp: z
    .object({ branchCode: z.string().min(1).meta({ title: 'Branch code' }) })
    .meta({ title: 'ERP' }),
});

export const configSchema = z.object({
  stores: z
    .record(z.string(), store)
    .default({})
    .meta({
      title: 'Stores',
      ui: { itemLabel: 'name', itemName: 'store', itemSubtitle: 'erp.branchCode' },
    }),
});
```

In the portal the collection becomes a searchable list on the left. Each item opens as one
continuous form, with its nested objects as sub-sections you can jump to. New items are validated
before the save, and the save button says what is missing.

![New hotel being created with validation errors on required fields](https://mintcdn.com/runflowai/omhuHUWlUZuaGpxb/images/agent-config/new-item-validation.jpg?fit=max\&auto=format\&n=omhuHUWlUZuaGpxb\&q=85\&s=d4a16a8972eb302c682c8cc607592c88)

A new item with required fields still empty: the save is blocked and the list marks the item

Some details worth knowing:

* **The key is the item id.** The portal suggests it from the name (`Hotel Copacabana` becomes
  `hotel-copacabana`). Use it in code and in logs. Keys can't contain `.`.
* **Defaults apply inside each item.** A field with `.default()` in the item schema is filled for
  every item, including items added later in the portal.
* **Duplicate** copies every field, including ids such as channel or branch codes. Change those
  before saving.
* **Items can be declared in the repository too.** An item in `manifest/staging.json` shows up in the
  portal, where the team can adjust its fields. It can't be removed from the portal, since the
  repository would bring it back on the next deploy.
* Always add `.default({})` to the collection, so the agent gets `{}` and not `undefined` before
  the first item exists.

See [One agent for many units](https://docs.runflow.ai/use-cases/multi-unit-agent.md) for a complete example routed by
WhatsApp number.

## Environments, promotion and history

Config follows the same [environments](https://docs.runflow.ai/core-concepts/environments.md) as the rest of the platform:

* **Staging** is where you edit. **Save in staging** creates a new version and it applies on the
  next staging execution.
* **Production** is read-only in the tab. It changes only through **Promote to production**, which
  shows exactly which fields change before you confirm. Promoting requires an admin of the workspace.
* **History** lists every version with author, date and message, the diff against the previous
  one, and **Restore**, which creates a new version with the old values. Nothing is overwritten.

> [!WARNING]
>
> Promote the **code** before the **config** that depends on it. If production still runs a version
> of the agent without a field, the promote dialog explains it and stays blocked until the code is
> promoted.

Defaults that differ per environment, such as a sandbox URL in staging, go in the repository files.
They must match the schema, and the deploy fails if they don't.

```json manifest/staging.json theme={null}
{
  "followUpHours": 1,
  "stores": {
    "store-test": { "name": "Test store", "channelIds": ["100000000000001"], "erp": { "branchCode": "T01" } }
  }
}
```

## Reading request metadata

Agents that pick a unit per request need something from the incoming request, like a store id or
the number that received the message. `readMetadata(input)` merges the metadata of the execution
input with the `metadata` sent in the request body by HTTP triggers, so the same code works for the
portal tester, the CLI and real traffic.

```ts theme={null}
import { readMetadata } from '@runflow-ai/sdk';

const { storeId } = readMetadata<{ storeId?: string }>(input);
```

> [!NOTE]
>
> The Conversation Hub sends the WhatsApp number as `phoneNumberId` at the top of the request body,
> not inside `metadata`. Read it from `input.request?.body?.phoneNumberId`; the
> [multi-unit guide](https://docs.runflow.ai/use-cases/multi-unit-agent.md#2-route-by-number) shows how to support both.

To test with realistic metadata in the portal, declare a **playground**. The fields become a form in
the agent tester and each fixture becomes a one-click scenario:

```ts manifest/index.ts theme={null}
export default defineManifest({
  config: configSchema,
  playground: {
    schema: z.object({ storeId: z.string().meta({ title: 'Store' }) }),
    fixtures: [
      { name: 'Downtown store', metadata: { storeId: 'downtown' } },
      { name: 'Unknown store', metadata: { storeId: 'nowhere' } },
    ],
  },
});
```

Fixtures can also live in `manifest/fixtures/*.json`, as an array of `{ name, description?, metadata }`.
Use fictional data: the CLI warns when a fixture looks like a real e-mail or phone number.

## CLI

| Command | What it does |
| - | - |
| `rf manifest` | Generates `agent.manifest.json` from `manifest/` and reports every problem with its path. Runs automatically on `rf agents deploy` |
| `rf config show [--env staging]` | Prints the effective value of each field and where it comes from (`code`, `repo` or `portal`) |
| `rf config pull [--env staging]` | Saves the portal values to `.runflow/config.local.json`, so `rf test` runs with them |
| `rf test` | Runs the agent locally with the repository defaults plus the pulled portal values |

```bash theme={null}
rf config show --env production
```

```text theme={null}
Config for Support (a1b2...) - production
Version 7 (published)

  PATH                     EFFECTIVE              SOURCE
  tone                     "casual"               portal
  followUpHours            24                     code
  handoffTopics            ["refund","complaint"] code
```

## Inspecting what the agent received

`configInfo()` tells you which version an execution ran with. Log it next to your business events
and you can tell later which settings produced a given answer.

```ts theme={null}
import { configInfo } from '@runflow-ai/sdk';

const info = configInfo();
// { source: 'snapshot', versionId: '...', version: 7 }
```

If `stale: true` shows up, the execution ran on a version of the code whose manifest differs from
the one the config was saved for. That happens briefly right after a deploy that changed the schema;
saving once in the portal clears it.

## Limits and good practices

* The resolved config of one environment can be up to **192 KB**. That fits dozens of units with
  long prompt instructions. If you need more, the data probably belongs in a
  [KV store](https://docs.runflow.ai/core-concepts/kv-store.md) or a [knowledge base](https://docs.runflow.ai/core-concepts/knowledge-rag.md).
* **Config is not a secret store.** Anyone who can open the agent can read it. Keep API keys and
  passwords in **Credentials** and reach them through [connectors](https://docs.runflow.ai/core-concepts/connectors.md).
* **Validate in the schema, not in the agent.** A `.min(1)` or `.max(168)` in the schema blocks a bad
  value in the portal before it reaches production.
* **Removing or renaming a field is a breaking change.** Values saved for a field that no longer
  exists are ignored and listed as invalid in the tab, so rename in two steps: add the new field,
  migrate, then remove the old one.
* **When the values don't match the schema** (for example, after a type change), `config()` logs a
  warning in the execution output and uses the values over the code defaults. Check the execution
  logs after changing a field's type.

## FAQ

### Do I need to redeploy after saving in the portal?

No. The next execution reads the new version.

### Who can change the config?

Members of the workspace can save in staging. Promoting to production and restoring a production
version require an admin.

### What happens to executions already running when I save?

They finish with the values they started with. Only new executions see the new version.

### Can I use a JSON Schema instead of Zod?

Yes. `defineManifest({ config })` also accepts a plain JSON Schema object. You lose type inference
and Zod transforms in `config()`.

## Next steps

> [card] [One agent for many units](https://docs.runflow.ai/use-cases/multi-unit-agent.md)
>
> A hotel chain served by a single agent, routed by WhatsApp number

> [card] [Environments](https://docs.runflow.ai/core-concepts/environments.md)
>
> How staging and production are separated


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.