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:
persona.ts(definePersona({...})) declares deployment metadata and runtime wiring, and points at the handler viaonEvent: './agent.ts'. It compiles topersona.json(a generated, gitignored artifact — authorpersona.ts, never the compiled JSON).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
references/agents/review/{persona.ts,agent.ts}— PR reviewer: harness run + VFS github reads, per-PR Slack thread, merge,capabilities.conflictResolvereferences/agents/repo-hygiene/{persona.ts,agent.ts}— sandboxed shell + Notion writeback via VFSwriteJsonFile/draftFilereferences/agents/linear/{persona.ts,agent.ts}— Linear Agent Session API (linearClient().agentActivity/respond/acknowledge) + thin-leadctx.workflow.rundelegationreferences/agents/linear-slack/{persona.ts,agent.ts}— harness-emits-fenced-actions rail, receipt-gated Linear writesreferences/agents/hn-monitor/{persona.ts,agent.ts}—@agentworkforce/deliverymulti-transport, threaded digest, two-tierctx.memory+ctx.filesstatereferences/agents/joke-bot/{persona.ts,agent.ts}—sandbox: falseconversational bot, triple transport,capabilities.conversationalreferences/agents/inbox-buddy/{persona.ts,agent.ts}—sandbox: truerequired for VFS Gmail reads, dual-transport, cross-turn memoryreferences/agents/gcp-watcher/{persona.ts,agent.ts}— token-free VFS monitor, dedup by signature, pure exportedevaluateSignalsreferences/agents/cloud-team-implementer/{persona.ts,agent.ts}andreferences/agents/cloud-team-reviewer/{persona.ts,agent.ts}— team members (launchedBy: 'team-dispatcher')
Workforce examples:
references/workforce/examples/review-agent/persona.jsonreferences/workforce/examples/review-agent/agent.tsreferences/workforce/examples/weekly-digest/persona.jsonreferences/workforce/examples/weekly-digest/agent.tsreferences/workforce/examples/linear-shipper/persona.jsonreferences/workforce/examples/linear-shipper/agent.tsreferences/workforce/examples/notion-essay-pr/persona.jsonreferences/workforce/examples/notion-essay-pr/agent.tsreferences/workforce/examples/proactive-issue-resolver/persona.jsonreferences/workforce/examples/proactive-issue-resolver/agent.ts
Current types and deploy checks:
references/workforce/packages/persona-kit/src/types.tsreferences/workforce/packages/runtime/src/types.tsreferences/workforce/packages/persona-kit/schemas/persona.schema.jsonreferences/workforce/packages/deploy/src/preflight.tsreferences/workforce/packages/deploy/src/extract-agent.tsreferences/workforce/packages/cli/src/deploy-command.tsreferences/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:
idintenttagsdescriptioncloud: trueuseSubscription(optional)integrations(optional, for provider connection requirements, mount scope, and adapter config passthrough — see Authoring rules 3 and 4)memory(optional; production agents use bothtrueand object form)onEvent- top-level runtime fields, when the agent uses a harness:
harnessmodelsystemPromptharnessSettings
- 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 optionallywatch; team-member agents can intentionally declare none and uselaunchedBy: 'team-dispatcher' - receives
ctxandeventinhandler - branches on
event.type(provider-prefixed dotted string, orcron.tick) - reads the payload via
await event.expand('full')(see "Event model (v4)") - reads and writes provider data through
@relayfile/relay-helpersclients (linearClient().comment(...),slackClient().post(...),githubClient().mergePullRequest(...), or the genericrelayClient(provider)/providerClient(provider)) — catalog-backed, no hardcoded paths. The raw@agentworkforce/runtimeVFS helpers (readJsonFile/writeJsonFile) stay the lower-level fallback. There are no per-provider clients onctx(noctx.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.*orctx.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:
Clock via
defineAgent({ schedules: [...] })- branch on
event.type === 'cron.tick' - the cron event carries
event.schedule(the cron expr / one-shot id) andevent.scheduledFor— there is noevent.name. For a single-schedule persona, treat anycron.tickas that schedule (see gotcha §G2).
- branch on
Radio via
defineAgent({ triggers: { <provider>: [...] } })- the event's
typeis the provider-prefixedonvalue: a trigger{ slack: [{ on: 'message.created' }] }deliversevent.type === 'slack.message.created' - branch with
event.type === '<provider>.<on>'(orevent.type.startsWith('<provider>.')for a whole provider) - a trigger can carry
paths: ['/slack/channels/${SLACK_CHANNEL}/**']to scope wake-routing before provisioning (andmatch: '@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 theintegrationskey in the form the adapter documents.
- the event's
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
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.tsandreferences/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 noevent.source. - Payload is async:
const data = (await event.expand('full')).data;— not a synchronousevent.payload.event.resourceis the resource handle;event.id/event.workspace/event.occurredAtstill exist. - Cron:
event.type === 'cron.tick'; the fired schedule isevent.schedule(cron expr / one-shot id) +event.scheduledFor. Noevent.name. - Import
WorkforceEvent(alias ofAgentEvent) for helper signatures; don't import the removedWorkforceProviderEvent.
Canonical v4 handler:
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):
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.scopedoes NOT interpolate inputs, though triggerpathsDO. You can writepaths: ['/slack/channels/${SLACK_CHANNEL}/**']indefineAgent, but the same${…}inscopeis 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'spickerTargetPath(inpersona-deploy.ts) rewrites a picker-gated collection scope to the single record the input resolves to, for reads and writebacks alike. Requirements, all four:optional: true(persona-kit requires it alongside the gate),enabledByInput: '<INPUT>'on the integration,- a
pickeron that input whoseprovidermatches the integration and whoseresourcematches the collection segment, - 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
defaultfrom 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:
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-mailread 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 compiledpersona.jsonactually 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.
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.materializationto Slack, Linear, Notion, Jira, or other providers unless that adapter has shipped and documented the same setting. For unsupported providers, usescopeplus handler-side filtering, or open an adapter follow-up instead of inventing persona config. - Pair materialization with a concrete
scopefor any files the handler reads beyond the triggering subtree.config.materializationdecides what the adapter syncs;scopedecides what the persona mount can see. - Keep
configprovider-owned. Do not put listener fields (triggers,schedules,watch) in it or underintegrations; those belong indefineAgent(...). - Verify the compiled persona preserves both
integrations.<p>.scopeandintegrations.<p>.configbefore 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:
weeklydaily-triagestale-pr-scan
Bad:
job1schedule-a
6. Memory should match the job
Examples:
workspacescope for shared team/project contextuserscope for per-user assistant continuityglobalonly 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
{
"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}/..., butslackClient().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 scopesslackrather than using"slack": {}, even thoughagent.tsbelow declares a slack trigger. Any integration the handler writes through needs a non-emptyscope("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).githubis still scoped here so the reviewer's reads (the PR records and/github/LAYOUT.mdit 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
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', withevent.schedule/event.scheduledFor(noevent.name) - provider events:
event.type === '<provider>.<on>'; the payload is(await event.expand('full')).data(async), notevent.payload
Do not invent custom event wrappers when @agentworkforce/runtime already provides them.
When reading provider payloads:
- treat the expanded
.dataas 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 inlineifblocks
Context usage guidance
The useful pieces on ctx are typically:
ctx.personactx.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 unlesspersona.memory.trajectoriesis 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:
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).
writeJsonFilenow THROWS on non-success. The runtime'swriteJsonFilewrapper (re-exported from@relayfile/adapter-core/vfs-client) normalizes the writeback status and throwsWritebackErrorunless the state issucceeded— the one exception iswritebackTimeoutMs: 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 rawwriteJsonFileto an unmounted path now surfaces as a thrownWritebackErrorrather 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 returnsts: ''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:
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.mdlives at/github/LAYOUT.md— a sibling ofrepos/, 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
githuborslack - optional
inputsfor topics/repos/channels
Agent:
defineAgent({ schedules: [...] })- branch on
event.type === 'cron.tick'(multi-schedule: matchevent.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 requirementsuseSubscription: trueif 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; usewriteJsonFile(...)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, orwatch - 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:
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)