# Creating Cloud Persona

> Use when creating, updating, or reviewing a Workforce cloud persona (`persona.ts` + `agent.ts`) for the current deploy/runtime shape. Covers cloud, useSubscription, integrations with scope mounting/enabledByInput gating/adapter config passthrough, inputs, memory, sandbox modes, onEvent, runtime fields, capabilities, defineAgent triggers/schedules/watch/team-dispatcher launch, provider IO via @relayfile/relay-helpers, multi-transport delivery, ctx.relay messaging, and the deploy flow.

- Skill: `agentworkforce/creating-cloud-persona` (Agent Skill, multi-file: 67 files)
- Install (CLI): `npx skillmds@latest add agentworkforce/creating-cloud-persona`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentworkforce/creating-cloud-persona/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: AgentWorkforce (https://skillmd.com/u/agentworkforce)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentworkforce/creating-cloud-persona

---


# Creating Cloud Persona

Use this skill when authoring a deployable Workforce persona in the **current** shape.

## Core rule

A cloud persona is two files that ship **as a pair**:

1. `persona.ts` (`definePersona({...})`) declares **deployment metadata and runtime wiring**, and points at the handler via `onEvent: './agent.ts'`. It compiles to `persona.json` (a generated, gitignored artifact — author `persona.ts`, never the compiled JSON).
2. `agent.ts` (`export default defineAgent({...})`) implements the **actual behavior**.

**Always ship both.** The runtime's composable-runtime closure treats `persona.ts`
as the canonical entry; a bare `agent.ts` with no sibling `persona.ts` falls back
to a **synthesized minimal-preview persona** (and emits a compatibility warning) —
you lose the real harness/model/integration/memory wiring. (The
`composable-runtime-closure` acceptance harness in the agents repo pins this: it
fails if a deploy synthesizes a persona instead of loading `persona.ts`.)

Important: **triggers, schedules, and watch rules are declared in `agent.ts` via `defineAgent(...)`, while `persona.ts` declares deploy/runtime config and integration connection requirements.**
The handler branches on `event.type` (a provider-prefixed dotted string like
`slack.message.created`, `linear.issue.create`, or `cron.tick`) and reads the
payload via `await event.expand('full')`. **See "Event model (v4)" below — the
pre-v4 `event.source` / `event.payload` / `event.name` shape is GONE in
runtime ≥ 4, and most of the inline examples further down still use it.**

## First read

Before authoring, read the vendored examples and current types in this skill's
`references/` directory. They are copied from the current Workforce and agents
repos so the skill is self-contained.

Production agents — each is a **`persona.ts` + `agent.ts` pair**. `persona.ts` is
the **authored source** (edit it, never the JSON). Each reference dir also carries
a `persona.json` — the **compiled artifact** (`agentworkforce persona compile
<dir>/persona.ts`, persona-kit 4.1.23) — vendored so you can see the shipped shape;
in the live agents repo that file is gitignored (`*/persona.json`) and regenerated
on demand:

- `references/agents/review/{persona.ts,agent.ts}` — PR reviewer: harness run + VFS github reads, per-PR Slack thread, merge, `capabilities.conflictResolve`
- `references/agents/repo-hygiene/{persona.ts,agent.ts}` — sandboxed shell + Notion writeback via VFS `writeJsonFile`/`draftFile`
- `references/agents/linear/{persona.ts,agent.ts}` — Linear Agent Session API (`linearClient().agentActivity/respond/acknowledge`) + thin-lead `ctx.workflow.run` delegation
- `references/agents/linear-slack/{persona.ts,agent.ts}` — harness-emits-fenced-actions rail, receipt-gated Linear writes
- `references/agents/hn-monitor/{persona.ts,agent.ts}` — `@agentworkforce/delivery` multi-transport, threaded digest, two-tier `ctx.memory` + `ctx.files` state
- `references/agents/joke-bot/{persona.ts,agent.ts}` — `sandbox: false` conversational bot, triple transport, `capabilities.conversational`
- `references/agents/inbox-buddy/{persona.ts,agent.ts}` — `sandbox: true` **required** for VFS Gmail reads, dual-transport, cross-turn memory
- `references/agents/gcp-watcher/{persona.ts,agent.ts}` — token-free VFS monitor, dedup by signature, pure exported `evaluateSignals`
- `references/agents/cloud-team-implementer/{persona.ts,agent.ts}` and `references/agents/cloud-team-reviewer/{persona.ts,agent.ts}` — team members (`launchedBy: 'team-dispatcher'`)

Workforce examples:

- `references/workforce/examples/review-agent/persona.json`
- `references/workforce/examples/review-agent/agent.ts`
- `references/workforce/examples/weekly-digest/persona.json`
- `references/workforce/examples/weekly-digest/agent.ts`
- `references/workforce/examples/linear-shipper/persona.json`
- `references/workforce/examples/linear-shipper/agent.ts`
- `references/workforce/examples/notion-essay-pr/persona.json`
- `references/workforce/examples/notion-essay-pr/agent.ts`
- `references/workforce/examples/proactive-issue-resolver/persona.json`
- `references/workforce/examples/proactive-issue-resolver/agent.ts`

Current types and deploy checks:

- `references/workforce/packages/persona-kit/src/types.ts`
- `references/workforce/packages/runtime/src/types.ts`
- `references/workforce/packages/persona-kit/schemas/persona.schema.json`
- `references/workforce/packages/deploy/src/preflight.ts`
- `references/workforce/packages/deploy/src/extract-agent.ts`
- `references/workforce/packages/cli/src/deploy-command.ts`
- `references/relayfile-adapters/packages/relay-helpers/README.md`

## Current persona shape to follow

Prefer the **actual shipped shape**, not older plan text.

For cloud personas, expect fields like:

- `id`
- `intent`
- `tags`
- `description`
- `cloud: true`
- `useSubscription` (optional)
- `integrations` (optional, for provider connection requirements, mount scope, and adapter config passthrough — see Authoring rules 3 and 4)
- `memory` (optional; production agents use both `true` and object form)
- `onEvent`
- top-level runtime fields, when the agent uses a harness:
  - `harness`
  - `model`
  - `systemPrompt`
  - `harnessSettings`
- optional `inputs`, `env`, `sandbox`, `skills`, `permissions`, `mount`, `mcpServers`, `capabilities`, `relay`

Do **not** author older `tiers` / `defaultTier` structures unless the repo explicitly still uses them. The latest Workforce examples use flat top-level runtime fields.

## Mental model

### `persona.json` does

- declares whether the persona is deployable
- chooses the harness/model/runtime knobs
- declares which integrations must be connected
- enables memory
- points at the handler entrypoint
- optionally declares capabilities/metadata

### `agent.ts` does

- exports `defineAgent({...})`
- declares `triggers`, `schedules`, and optionally `watch`; team-member agents
  can intentionally declare none and use `launchedBy: 'team-dispatcher'`
- receives `ctx` and `event` in `handler`
- branches on `event.type` (provider-prefixed dotted string, or `cron.tick`)
- reads the payload via `await event.expand('full')` (see "Event model (v4)")
- reads and writes provider data through **`@relayfile/relay-helpers`** clients (`linearClient().comment(...)`, `slackClient().post(...)`, `githubClient().mergePullRequest(...)`, or the generic `relayClient(provider)` / `providerClient(provider)`) — catalog-backed, no hardcoded paths. The raw `@agentworkforce/runtime` VFS helpers (`readJsonFile` / `writeJsonFile`) stay the lower-level fallback. There are **no** per-provider clients on `ctx` (no `ctx.github` / `ctx.linear`)
- optionally calls `ctx.harness.run(...)`
- optionally calls `ctx.llm.complete(...)` for smaller synthesis
- optionally delegates to `ctx.workflow.run(...)`
- optionally uses `ctx.files.*` or `ctx.sandbox.*`
- optionally uses `ctx.memory.*`
- performs the actual workflow

## Trigger model

Cloud agents currently have four practical shapes, and wakeups are authored in
`agent.ts`:

1. **Clock** via `defineAgent({ schedules: [...] })`
   - branch on `event.type === 'cron.tick'`
   - the cron event carries `event.schedule` (the cron expr / one-shot id) and
     `event.scheduledFor` — there is **no** `event.name`. For a single-schedule
     persona, treat any `cron.tick` as that schedule (see gotcha §G2).

2. **Radio** via `defineAgent({ triggers: { <provider>: [...] } })`
   - the event's `type` is the **provider-prefixed** `on` value: a trigger
     `{ slack: [{ on: 'message.created' }] }` delivers `event.type ===
     'slack.message.created'`
   - branch with `event.type === '<provider>.<on>'` (or
     `event.type.startsWith('<provider>.')` for a whole provider)
   - a trigger can carry `paths: ['/slack/channels/${SLACK_CHANNEL}/**']` to
     **scope wake-routing before provisioning** (and `match: '@mention'` /
     `where: 'field=value'` to gate further — see §2 wake-cost). The `${INPUT}`
     token is **deploy-time input-ref substitution inside a single-quoted
     string** (resolved from the persona input at deploy), **not** JS template
     interpolation — write it literally, don't backtick-interpolate it.
   - provider names in triggers are **canonicalized at deploy** via known
     aliases (e.g. `google-mail` → `gmail`), so an alias in a trigger still
     matches its integration; keep the `integrations` key in the form the
     adapter documents.

3. **Relayfile watch** via `defineAgent({ watch: [...] })`
   - for file/path-driven proactive behavior
   - keep this for cases that are truly about Relayfile path changes, not provider event hooks

4. **Team member** via `defineAgent({ launchedBy: 'team-dispatcher', handler })`
   - no direct triggers/schedules/watch
   - launched by a lead/team dispatcher to avoid duplicate subscriptions
   - see `references/agents/cloud-team-implementer/agent.ts` and
     `references/agents/cloud-team-reviewer/agent.ts`

`persona.json.integrations` still matters, but for **connection/setup**, not for declaring which events fire the handler.

## Event model (v4) — verified against workforce 4.1.34 (agents repo pins runtime/persona-kit 4.1.23)

The handler `event` is the relay SDK's normalized `AgentEvent`
(`@agent-relay/events`), narrowed by `defineAgent` to the triggers/schedules you
declared. **The pre-v4 `{ source, payload, workspaceId }` shape — and
`WorkforceProviderEvent` / `WorkforceCronEvent` — were removed.** Authoring
against them fails to typecheck (`has no exported member 'WorkforceProviderEvent'`,
`Property 'source' does not exist`). Many examples below still show the old shape;
prefer this section.

- **Discriminant is `event.type`** — a dotted, provider-prefixed string:
  `cron.tick`, `slack.message.created`, `linear.issue.create`,
  `github.pull_request.opened`. There is no `event.source`.
- **Payload is async**: `const data = (await event.expand('full')).data;` — not a
  synchronous `event.payload`. `event.resource` is the resource handle;
  `event.id` / `event.workspace` / `event.occurredAt` still exist.
- **Cron**: `event.type === 'cron.tick'`; the fired schedule is `event.schedule`
  (cron expr / one-shot id) + `event.scheduledFor`. **No `event.name`.**
- **Import** `WorkforceEvent` (alias of `AgentEvent`) for helper signatures;
  don't import the removed `WorkforceProviderEvent`.

Canonical v4 handler:

```ts
import { defineAgent, type WorkforceCtx, type WorkforceEvent } from '@agentworkforce/runtime';

export default defineAgent({
  schedules: [{ name: 'daily', cron: '0 9 * * 1-5' }],
  triggers: { github: [{ on: 'pull_request.opened' }], slack: [{ on: 'message.created', match: '@mention' }] },
  handler: async (ctx, event) => {
    if (event.type === 'cron.tick') return runDaily(ctx);            // single schedule → no name gate
    if (event.type === 'github.pull_request.opened') {
      const data = (await event.expand('full')).data;               // payload is async
      return reviewPr(ctx, data);
    }
    if (event.type === 'slack.message.created') {
      const data = (await event.expand('full')).data;
      return replyMention(ctx, data);
    }
  }
});
```

Exhaustiveness note: when your declared triggers/schedules narrow `event` to a
closed union and you handle every case, a trailing `event.type` access is `never`
and won't typecheck — drop the unreachable fallback rather than casting.

## Authoring rules

### 1. Prefer one `defineAgent(...)` file

Default to one `agent.ts` per persona, exporting one `defineAgent({...})` with internal branching:

- `if (event.type === 'cron.tick') ...`
- `if (event.type === 'github.pull_request.opened') ...`

Do not split into many handlers unless the behavior is truly large.

### 2. Keep wakeups declarative in `defineAgent(...)`, behavior imperative in the handler

Use `defineAgent(...)` to declare **what can wake the agent**.
Do not try to encode the workflow in `persona.json`.
The actual routing and business logic belong in `agent.ts`.

### 3. Only declare integrations the agent actually requires — with a `scope`

If `agent.ts` never uses Slack behavior or Slack-backed writes, do not declare Slack in `persona.json` just because it might be useful later.

And for the integrations you *do* declare, **also declare a mount `scope`**. The
persona-kit type is
`PersonaIntegrationConfig { source?: IntegrationSource; scope?: Record<string, string>; config?: Record<string, unknown>; optional?: boolean; enabledByInput?: string }`,
where `scope` maps a resource name to an absolute relayfile glob, `config`
passes provider-owned adapter settings through unchanged, and `optional` /
`enabledByInput` gate the integration on a deploy input (see §3b). An **unscoped
provider mirror is dropped** — `slack: {}` (and `scope: {}`) mounts no provider
data, so reads come back empty and writes land on unmounted disk as silent
no-ops. Prefer the concrete subpaths the handler actually reads and writes back
to (least privilege — the relayfile token's path scope derives from the mount);
a broad `/provider/**` is valid but mounts the whole provider, and a mid-path
`*` mounts nothing (see §1):

```ts
integrations: {
  // Picker-narrowed: cloud rewrites this to /slack/channels/<resolved id>/** at
  // deploy. Needs `enabledByInput` AND a matching `picker` on that input — see
  // "scope is a boot cost" below. Leave the scope string as the bare collection.
  slack: {
    optional: true,
    enabledByInput: 'SLACK_CHANNEL',
    scope: { channels: '/slack/channels/**' }
  },
  // Read-only Linear context — scope the concrete subpaths the handler reads.
  linear: { scope: { projects: '/linear/projects/**', issues: '/linear/issues/**' } }
},
inputs: {
  SLACK_CHANNEL: {
    description: 'Channel the agent posts to.',
    env: 'SLACK_CHANNEL',
    picker: { provider: 'slack', resource: 'channels' }   // ← what enables the rewrite
  }
}
```

**Scope is a boot cost, not just a permission.** The mount is *traversed* when
the sandbox starts, so its size is paid on every run. Collections that grow with
workspace *history* rather than with configuration — `/slack/channels/**`,
`/google-mail/messages/**`, `/google-mail/threads/**` — get big enough to matter:
`/slack/channels/**` measured ~5,950 entries (2,008 files, 3,940 directories) in
one real workspace. That mount could not converge inside its budget, so runs
either came up degraded (`scoped initial sync failed; continuing without
preloaded reads`) or, once cloud began cancelling non-converging mounts at the
hard deadline, failed outright with exit 124. Narrowing to the single channel
took the same agent's bootstrap to a clean 127s.

Two corollaries worth internalizing:

- **Scope the one path you write to — not the collection around it.** The agent
  above kept posting through the runs that logged `scoped initial sync failed;
  continuing without preloaded reads`: the mount was up, only the *preload* had
  been skipped, and the writeback receipt still came back. Mirroring 6,000
  entries to send one message bought nothing.

  Do not read that as "writes don't need the mount". They do. A mirror that is
  genuinely *stuck* — as opposed to merely un-preloaded — cannot acknowledge a
  writeback either, which returns `ts: ''` and marks the whole run FAILED on the
  teardown flush (see §1). The narrow scope is the fix for both: it is cheap
  enough to actually converge.
- **`scope` does NOT interpolate inputs, though trigger `paths` DO.** You can
  write `paths: ['/slack/channels/${SLACK_CHANNEL}/**']` in `defineAgent`, but the
  same `${…}` in `scope` is not substituted — it is matched literally and mounts
  nothing.

  **Do not work around this by hard-coding the id in `scope`.** That pins one
  channel and takes the choice away from whoever deploys; override the input and
  the agent silently writes to a channel it has no grant for. Use the gate above
  instead: cloud's `pickerTargetPath` (in `persona-deploy.ts`) rewrites a
  picker-gated collection scope to the single record the input resolves to, for
  reads and writebacks alike. Requirements, all four:

  1. `optional: true` (persona-kit requires it alongside the gate),
  2. `enabledByInput: '<INPUT>'` on the integration,
  3. a `picker` on that input whose `provider` matches the integration and whose
     `resource` matches the collection segment,
  4. the scope left as the bare collection (`/slack/channels/**`) — the rewrite
     matches that exact path and nothing else.

  Miss any one and it silently falls back to mirroring the whole collection.
  A hard-coded constant is the fallback only for an agent whose channel genuinely
  is fixed and not operator-chosen; if you do that, drive the input `default` from
  the same constant and assert in a test that the two agree.

`deploy` runs `lintScopes()` (persona-kit >= 4.1.42) over these globs and warns
non-fatally on the history-sized collections above, on provider-root mirrors, and
on `/`-leading globs the mount would reject outright. A correctly picker-gated
collection is **not** flagged — the lint stays quiet exactly where cloud narrows
for you, so a warning on one means the narrowing is not actually wired up.
Warnings are advice, not a gate: if the agent genuinely reads the whole
collection, keep it.

The full mechanics and the labelled-mirror sub-trap are in the
production-correctness checklist below (§1).

### 3b. Gate conditional integrations with `optional` + `enabledByInput`

By default every declared integration is connected at deploy: its provider
credential is required and its triggers register. That is wrong for an
integration only *some* deploys use — declaring it unconditionally forces every
deployer to connect a provider they may not want.

`optional: true` + `enabledByInput: '<INPUT>'` (persona-kit ≥ 4.1.12,
workforce#252) make an integration **opt-in**: its provider connection, trigger
registration, and mount happen ONLY when the named input resolves to a non-empty
value (resolution order: `--input` flag > env var > input default). When the
input is empty the whole integration is pruned before connection. `optional: true`
**requires** `enabledByInput` (the parser rejects one without the other).

The canonical use is a **dual-transport agent** — one agent that declares both
`slack` and `telegram` and lets configuration pick which one(s) run, so a
Slack-only deploy never has to wire up a Telegram bot, and vice versa:

```ts
integrations: {
  slack: {
    optional: true,
    enabledByInput: 'SLACK_CHANNEL',          // set SLACK_CHANNEL → Slack connects
    // Broad here only because this agent replies wherever it is mentioned. If
    // yours targets known channels, scope them individually (§3) — `scope` is a
    // boot cost and does not interpolate `SLACK_CHANNEL`.
    scope: { channels: '/slack/channels/**' }
  },
  telegram: {
    optional: true,
    enabledByInput: 'TELEGRAM_CHAT',          // set TELEGRAM_CHAT → Telegram connects
    scope: { chats: '/telegram/chats/**', layout: '/telegram/LAYOUT.md' }
  }
},
inputs: {
  SLACK_CHANNEL: { env: 'SLACK_CHANNEL', optional: true, picker: { provider: 'slack', resource: 'channels' } },
  TELEGRAM_CHAT: { env: 'TELEGRAM_CHAT', optional: true }
}
```

The handler then registers both triggers (`slack: [...]`, `telegram: [...]`),
dispatches by `event.type` (`slack.*` vs `telegram.*`), and replies on the
**origin transport** so a message asked in one channel isn't mirrored to the
other. The unconfigured transport's trigger never fires because it was pruned.

Authoring rules:

- **Gate-on-id semantics:** the gating input typically doubles as the
  channel/chat/user id, so providing it both *enables* and *restricts* that
  transport. Decide deliberately — if you need "enabled but unrestricted", gate
  on a separate dedicated input instead of the id.
- Keep always-on data sources (e.g. a `google-mail` read mount) **non-optional** —
  only gate the transports/integrations a deploy can legitimately skip.
- Require persona-kit ≥ 4.1.12 in the agents repo. Older versions silently drop
  `optional`/`enabledByInput` (the integration would connect unconditionally),
  so verify the compiled `persona.json` actually carries the fields before
  deploy.

### 4. Use `integrations.<provider>.config` for adapter behavior, not mount behavior

`integrations.<provider>.config` is a forward-compatible adapter passthrough.
Persona-kit validates only that it is a plain object and preserves it for the
cloud adapter. It does **not** mount files, grant writeback path scope, or wake
the handler. Keep using `scope` and `defineAgent(...)` triggers for those.

Use `config` only for adapter settings that the provider explicitly documents.
It is not a portable cross-provider materialization API. The current production
case is **GitHub-only** materialization from `relayfile-adapters#193`: a persona
can keep GitHub lazy by default while eagerly materializing issues or pulls for
selected repositories.

```ts
integrations: {
  github: {
    scope: { paths: '/github/**' },
    config: {
      materialization: {
        default: 'lazy',
        webhookWritesForLazyRepos: true,
        rules: [
          {
            repos: ['AgentWorkforce/cloud'],
            issues: {
              mode: 'eager',
              filter: { state: 'open', labels: ['factory'] }
            },
            pulls: 'lazy'
          }
        ]
      }
    }
  }
}
```

Authoring rules:

- Use canonical GitHub materialization modes: `'lazy'` and `'eager'`. Adapter
  runtime aliases like `'all'` / `'none'` are not typed persona authoring
  values.
- Do not copy `config.materialization` to Slack, Linear, Notion, Jira, or other
  providers unless that adapter has shipped and documented the same setting.
  For unsupported providers, use `scope` plus handler-side filtering, or open an
  adapter follow-up instead of inventing persona config.
- Pair materialization with a concrete `scope` for any files the handler reads
  beyond the triggering subtree. `config.materialization` decides what the
  adapter syncs; `scope` decides what the persona mount can see.
- Keep `config` provider-owned. Do not put listener fields (`triggers`,
  `schedules`, `watch`) in it or under `integrations`; those belong in
  `defineAgent(...)`.
- Verify the compiled persona preserves both `integrations.<p>.scope` and
  `integrations.<p>.config` before deploy.

### 5. Schedules are named APIs

Declare schedules in `defineAgent({ schedules: [...] })`, not in `persona.json`.

Every schedule name should mean something operationally useful — it documents
the wakeup and (for multi-schedule personas) maps to the `event.schedule` cron
expression you match on. (v4: the handler does NOT receive `event.name`; see
"Event model (v4)" and G2.)

Good:

- `weekly`
- `daily-triage`
- `stale-pr-scan`

Bad:

- `job1`
- `schedule-a`

### 6. Memory should match the job

Examples:

- `workspace` scope for shared team/project context
- `user` scope for per-user assistant continuity
- `global` only when cross-workspace memory is truly intended

Do not enable memory by reflex if the persona is purely stateless.

### 7. `systemPrompt` should define the agent’s role, not the listener plumbing

The prompt should say what kind of agent this is and what quality bar it follows.
Do not stuff listener-routing details into the prompt when they are already in code.

## Good starter pattern

Use this shape unless there is a strong reason not to.

### persona.json

```json
{
  "id": "review-agent",
  "intent": "review",
  "tags": ["review", "github"],
  "description": "Reviews PRs, responds to mentions, and reacts to failed CI.",
  "cloud": true,
  "useSubscription": true,
  "integrations": {
    "github": { "scope": { "paths": "/github/**" } },
    "slack": { "scope": { "paths": "/slack/channels/**" } }
  },
  "memory": {
    "enabled": true,
    "scopes": ["workspace"]
  },
  "onEvent": "./agent.ts",
  "harness": "codex",
  "model": "gpt-5.5",
  "systemPrompt": "Review pull requests for correctness, regression risk, security concerns, and missing tests. Be concise and concrete.",
  "harnessSettings": {
    "reasoning": "medium",
    "timeoutSeconds": 1200,
    "sandboxMode": "workspace-write",
    "workspaceWriteNetworkAccess": true
  }
}
```

> **Scope warning — a Slack trigger does NOT cover a Slack write.** Cloud mounts
> an integration's relayfile paths from triggers and from `scope`, nothing else.
> A trigger mounts a *read* mirror at the display-labelled path
> `/slack/channels/{id}__{name}/...`, but `slackClient().post()` writes to the
> canonical bare-id path `/slack/channels/{id}/messages` — the two never
> coincide, so a slack trigger alone leaves every write a silent no-op. That is
> why this example **scopes** `slack` rather than using `"slack": {}`, even
> though `agent.ts` below declares a slack trigger. Any integration the handler
> **writes** through needs a non-empty `scope`
> (`"slack": { "scope": { "paths": "/slack/channels/**" } }`); github/linear
> writes are the exception only because their trigger and writeback paths share
> one bare-id form. The scope is broad here because this reviewer answers
> mentions anywhere; an agent that posts to known channels should scope them
> individually, since the whole mirror is traversed at boot (§3).
> `github` is still scoped here so the reviewer's **reads**
> (the PR records and `/github/LAYOUT.md` it walks beyond its trigger subtree)
> are mounted — an unscoped `"github": {}` mirror is dropped. `scope: {}` is
> discarded by persona-kit, and scope values must be strings. Full rules are in
> the production-correctness checklist below (§1).

### agent.ts

```ts
import { defineAgent } from '@agentworkforce/runtime';

export default defineAgent({
  triggers: {
    github: [
      { on: 'pull_request.opened' },
      { on: 'issue_comment.created', match: '@mention' },
      { on: 'check_run.completed', where: 'conclusion=failure' }
    ],
    slack: [{ on: 'app_mention' }]
  },
  schedules: [{ name: 'daily-triage', cron: '0 9 * * 1-5', tz: 'UTC' }],
  handler: async (ctx, event) => {
    // event.type is provider-prefixed; payload is async (see "Event model (v4)").
    if (event.type === 'github.pull_request.opened') {
      const data = (await event.expand('full')).data;
      return; // review flow
    }
    if (event.type === 'github.issue_comment.created') {
      const data = (await event.expand('full')).data;
      return; // mention reply flow
    }
    if (event.type === 'github.check_run.completed') {
      const data = (await event.expand('full')).data;
      return; // failed-CI reaction flow
    }
    if (event.type === 'slack.app_mention') {
      const data = (await event.expand('full')).data;
      return; // slack reply flow
    }
    if (event.type === 'cron.tick') {
      return; // scheduled flow (single schedule → no name gate)
    }
  }
});
```

## Event-shape guidance

Use the runtime’s current v4 event model (see "Event model (v4)"):

- cron events: `event.type === 'cron.tick'`, with `event.schedule` / `event.scheduledFor` (no `event.name`)
- provider events: `event.type === '<provider>.<on>'`; the payload is `(await event.expand('full')).data` (async), not `event.payload`

Do not invent custom event wrappers when `@agentworkforce/runtime` already provides them.

When reading provider payloads:

- treat the expanded `.data` as provider-normalized but still loosely typed
- write small local extractor helpers instead of spreading unsafe casts everywhere
- validate required identifiers early and fail clearly
- prefer `defineAgent({...})` + helper functions over giant inline `if` blocks

## Context usage guidance

The useful pieces on `ctx` are typically:

- `ctx.persona`
- `ctx.harness.run(...)`
- `ctx.llm.complete(...)`
- `ctx.memory.save(...)`
- `ctx.memory.recall(...)`
- `ctx.sandbox.*`
- `ctx.files.*`
- `ctx.schedule.*`
- `ctx.workflow.*`
- `ctx.relay.dm(to, text)` / `ctx.relay.post(channel, text)` — **agent-to-agent** messaging over the relay (DM a peer agent by registered name, or post to a relay channel). Returns `{ ok, messageId? }` and **never throws** (`{ ok: false }` on failure). Use it to answer a relay-inbox DM (`isRelaycastMessageEvent`) or hand off to a peer agent — not for user-facing provider posts (those go through `@relayfile/relay-helpers`).
- `ctx.trajectory.*` — auto-recorded decision trail (no-op unless `persona.memory.trajectories` is opted in); always safe to call.
- `ctx.log(...)`

Prefer direct typed runtime helpers over invoking external commands.

### Provider reads and writes — use `@relayfile/relay-helpers`

There are **no** `ctx.<provider>` clients. The ergonomic way to talk to a
provider is **`@relayfile/relay-helpers`** — opt-in factory clients whose paths
come from the adapter catalog (so they can't drift from the adapter). Add
`@relayfile/relay-helpers` to the persona's `package.json`, then:

```ts
import { linearClient, slackClient, githubClient } from '@relayfile/relay-helpers';

const linear = linearClient();                   // binds the mount root once (RELAYFILE_MOUNT_ROOT)
const issue = await linear.getIssue(issueId);    // read
await linear.comment(issueId, ':rocket: done');  // write

await githubClient().comment({ owner, repo, number }, 'LGTM');
await githubClient().mergePullRequest({ owner, repo, number, method: 'squash' });
await slackClient().post('#eng', 'shipped');
await slackClient().dm(userId, 'heads up');
```

A write is a draft file the Relayfile writeback worker turns into the real
provider call (with retry/durability) — handlers never hold a token or call a
provider REST API directly.

The catalog now exposes **36 provider clients** (31 generated + 5 bespoke —
`githubClient`, `linearClient`, `slackClient`, `redditClient`, `telegramClient`),
plus the `providerClient` / `relayClient` escape hatches. (The old "all-29" count
is stale; the number is catalog-driven and grows as adapters ship, enforced by an
in-sync test.) When a provider has no bespoke method for what you need, use the
generic resource access — `providerClient('notion').pages.write({ databaseId }, {...})`
— or `relayClient('linear').write('issues', {}, {...})` when you need the raw
writeback **receipt** (`{ path, receipt: { url, id, identifier } }` — the created
record's URL/id).

**Delivery status is explicit now — don't treat a returned handle as delivered.**
The github/linear **create** helpers return a discriminated `CreatedResult`:
`status: 'confirmed' | 'pending' | 'dropped'`, plus `path` (the draft handle,
always present), `id` (falls back to `path` until a provider receipt supplies a
real id), and `url` (empty string until confirmed — never a filesystem path).
Idempotency rules: on **`pending`**, do NOT throw or retry (a retry can duplicate
the provider-side effect); **`dropped`** requires positive evidence the draft
won't be handled; genuinely ambiguous failures (admission timeout) still throw.
Never promote `pending` → `dropped` yourself. Slack's `post`/`dm`/`reply` keep
the older shape — they return `{ ts }` (`ts: ''` when no receipt arrived, a
**silent** non-delivery — see §1/§9), not a `CreatedResult`.

**Multi-transport delivery — `@agentworkforce/delivery`.** For an agent that
fans one message out to whichever transports are configured (Slack and/or
Telegram), prefer the delivery helper over hand-rolling per-provider posts:
`createDelivery(ctx, undefined, ['slack', 'telegram'])` exposes `.targets`,
`.publish()`, and `.send(text, { replyTo, nonBlocking })`. **Thread by passing a
header's `DeliveryResult` as `replyTo`** (not a raw `thread_ts`). hn-monitor uses
this for its threaded digest; agents that still hand-roll (spotify-releases) call
`slackClient().dm()` + a shared `../shared/telegram.ts` helper. Either way, the
idempotency rule from the notes holds: once the header has posted, don't throw —
a retried handler re-posts a duplicate header.

**Preview-safety (composable-runtime closure).** Handlers run under a closure that
records every side effect: provider writes land as `previewed` actions,
`memory`/`files` writes replay, and — importantly — **undeclared outbound HTTP is
denied**. A raw `GET`/`HEAD` via `node:http`/`fetch` that isn't allow-listed in
`capabilities.httpRead` is rejected before it runs (POSTs always denied). If a
handler must read a live URL, declare it (see §5b `httpRead`); otherwise
prefer VFS/provider reads.

**Lower-level escape hatch.** For reads that are *not* catalog writeback
resources (e.g. a github PR's record JSON, a provider's `_index.json`), drop to
the generic VFS helpers from `@agentworkforce/runtime`
(`readJsonFile`/`listJsonFiles`/`writeJsonFile`/`draftFile`).

> **`writeJsonFile` now THROWS on non-success.** The runtime's `writeJsonFile`
> wrapper (re-exported from `@relayfile/adapter-core/vfs-client`) normalizes the
> writeback status and **throws `WritebackError` unless the state is
> `succeeded`** — the one exception is `writebackTimeoutMs: 0` + `no_receipt`,
> which returns the result without throwing (used for fire-and-thread posts). This
> is a change from older runtimes where the low-level write returned silently on
> timeout. Two consequences: (a) a raw `writeJsonFile` to an **unmounted** path
> now surfaces as a thrown `WritebackError` rather than a silent no-op — good, but
> catch it where a partial failure shouldn't fail the whole handler; (b) the
> **relay-helpers Slack client still returns `ts: ''` silently** (it uses its own
> transport, not this wrapper), so the §1 "make delivery loud" rule for Slack
> still stands.

**Never assume a record path — the mount self-describes its layout.** The
relayfile adapter publishes a guide per provider at `/<provider>/LAYOUT.md`
(e.g. `/github/LAYOUT.md`) and an `_index.json` at each level. Its first rule is
literally *"always run `ls` before constructing a path"*, because record
directory names are **not guessable**: a GitHub PR is
`pulls/<number>__<slug>/meta.json` (number + sanitized title slug), **not**
`pulls/<number>/meta.json`. Read `LAYOUT.md`, walk the `_index.json` files, and
`ls`/inspect a directory before reading from it:

```ts
import { readJsonFile, resolveMountRoot } from '@agentworkforce/runtime';
import { readdir } from 'node:fs/promises';
import path from 'node:path';

const root = resolveMountRoot({});
// LAYOUT.md + _index.json are the source of truth — read them, don't hardcode.
const pullsDir = path.join(root, 'github', 'repos', owner, repo, 'pulls');
const entry = (await readdir(pullsDir)).find((d) => d.startsWith(`${prNumber}__`));
if (!entry) throw new Error(`PR #${prNumber} not found under ${pullsDir}`);
const meta = await readJsonFile(
  { relayfileMountRoot: root }, 'github', 'getPr',
  `/github/repos/${owner}/${repo}/pulls/${entry}/meta.json`
);
```

> **Scope it in.** `LAYOUT.md` lives at `/github/LAYOUT.md` — a sibling of
> `repos/`, **not** under it. A scope like `/github/repos/<owner>/**` does NOT
> mount the guide; use `/github/**` (or otherwise include `/github/LAYOUT.md`)
> if the handler should read it.

When unsure of a resource or path, prefer the in-mount `LAYOUT.md` / `_index.json`
(runtime truth), then the catalog (`@relayfile/adapter-core/writeback-paths`) or
the adapter's `resources.ts` — never guess a filename.

## When to use `ctx.harness.run(...)`

Use the harness when the persona needs real judgment or synthesis, for example:

- PR review comments
- replies to mentions
- code-fix suggestions
- summarization
- clustering and writing human-facing output

Do not use the harness for simple deterministic routing, field extraction, or formatting that plain TypeScript can do more safely.

**`[[NO_REPLY]]` — the supported silent-reply marker.** When a conversational
harness prompt decides there's nothing worth saying, have it emit the reserved
`[[NO_REPLY]]` marker. The runtime **strips the marker** before returning
`output` and sets `result.suppressed = true` (with `result.containsMarker`); a
`suppressed` result on `exitCode 0` is an intentional silent success, **not** a
failure — branch on it to skip the reply rather than posting an empty message.
`HarnessRunResult` also now carries `stderr` (folded into `output` on a non-zero
exit, so failure reasons are visible to callers that only read `output`).

## Inputs and env

Use `inputs` when the value is a declared runtime parameter for the persona, like:

- target repo
- topic list
- destination channel
- project code

Use `env` only for environment variables the harness process needs.
Do not put secrets into `inputs`.

## Common patterns

### Scheduled digest

Use when the agent runs on a cron schedule and writes a summary somewhere.

Persona:

- `cloud: true`
- integration connection declarations like `github` or `slack`
- optional `inputs` for topics/repos/channels

Agent:

- `defineAgent({ schedules: [...] })`
- branch on `event.type === 'cron.tick'` (multi-schedule: match `event.schedule`)
- fetch/search/gather
- summarize
- post or upsert
- save memory if the artifact matters later

### Integration-triggered reviewer

Use when the agent wakes on GitHub, Linear, Slack, etc.

Persona:

- `integrations.<provider>` for connection requirements
- `useSubscription: true` if the judgment should run on the user’s linked provider path
- often `memory.workspace`

Agent:

- `defineAgent({ triggers: { <provider>: [...] } })`
- branch on `event.type` (`'<provider>.<on>'`, or `.startsWith('<provider>.')`)
- extract target identifiers from `(await event.expand('full')).data`
- optionally load prior memory
- call harness for judgment/output
- write back with `@relayfile/relay-helpers`; use `writeJsonFile(...)` only
  for lower-level VFS/resource cases that the helper catalog does not cover

### Mixed schedule + integrations agent

Fine to combine both in one cloud agent when the role is coherent.
Examples:

- responds to Slack mentions and also runs a daily cleanup
- reacts to GitHub events and runs a weekly scan

Do not combine unrelated jobs into one agent just because the runtime allows it.

### Team member agent

Use when the persona is launched by a team dispatcher, not directly by provider
events.

Persona:

- `cloud: true`
- usually declares integrations needed by the member's sandbox/work
- harness/model/systemPrompt/harnessSettings describe the member role
- `onEvent: "./agent.ts"`

Agent:

- `defineAgent({ launchedBy: 'team-dispatcher', handler })`
- no `triggers`, `schedules`, or `watch`
- handler should usually log and return if invoked directly
- do not subscribe team members to the same provider events as the lead, or the
  lead and every member can fire for the same issue/PR

## Production correctness checklist

These rules came from shipped Workforce/agents defects. Apply them after the basic persona shape is in place and before deploy.

## 1. THE INTEGRATION SCOPE TRAP — declared ≠ mounted

**A persona integration without a `scope` mounts nothing.** Cloud derives the
relayfile mount paths (and the relayfile token's path scope) from exactly two
sources: the agent's **triggers** and each integration's **scope**
(`cloud → packages/web/lib/proactive-runtime/persona-deploy.ts`,
`relayfilePathsFromScope`). A bare declaration like:

```ts
integrations: {
  github: {},
  slack: {}     // ← INERT: no trigger, no scope → zero /slack paths mounted
}
```

means `slackClient().post(...)` writes its draft JSON to **unmounted local
disk**, polls ~3s for a writeback receipt that can never arrive, and returns
`{channel, ts: ''}` **without throwing** (adapter-core `vfs-client`
`writeJsonFile` → `waitForReceipt` returns `undefined` on timeout). The
notification is a perfectly silent no-op — this shipped as the pr-reviewer
Slack bug (agents#40).

The same `ts: ''` signature also appears when the scope **is** set but the
mount's read-side mirror never finished bootstrapping (e.g. a file/dir path
collision aborts every sync cycle), so the writeback can't be acknowledged — an

…(truncated)
