name: creating-cloud-persona
description: Use when creating, updating, or reviewing a Workforce cloud persona (persona.json/persona.ts + agent.ts) for the current deploy/runtime shape. Covers cloud, useSubscription, integrations with scope mounting and adapter config passthrough, inputs, memory, sandbox modes, onEvent, top-level runtime fields, defineAgent(...) triggers/schedules/watch/team-dispatcher launch, provider IO via @relayfile/relay-helpers, production-correctness traps, vendored examples, and deploy flow. Use for requests like “create a cloud persona”, “write a deployable workforce persona”, “add integrations to a persona”, “configure GitHub materialization for a persona”, “review a workforce persona”, or “author the agent.ts handler for a workforce persona”.
Creating Cloud Persona
Use this skill when authoring a deployable Workforce persona in the current shape.
Core rule
A cloud persona is two things together:
persona.jsondeclares deployment metadata and runtime wiringagent.tsimplements the actual behavior
Important: triggers, schedules, and watch rules are declared in agent.ts via defineAgent(...), while persona.json declares deploy/runtime config and integration connection requirements.
The handler branches on event.source and event.type or event.name.
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:
references/agents/review/persona.jsonreferences/agents/review/agent.tsreferences/agents/repo-hygiene/persona.jsonreferences/agents/repo-hygiene/agent.tsreferences/agents/linear/persona.jsonreferences/agents/linear/agent.tsreferences/agents/hn-monitor/persona.jsonreferences/agents/hn-monitor/agent.tsreferences/agents/cloud-team-implementer/persona.jsonreferences/agents/cloud-team-implementer/agent.tsreferences/agents/cloud-team-reviewer/persona.jsonreferences/agents/cloud-team-reviewer/agent.ts
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 - inspects
event.source - inspects
event.typeorevent.name - 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: [...] })- runtime event source:
cron - branch on
event.source === 'cron' - discriminate with
event.name
- runtime event source:
Radio via
defineAgent({ triggers: { <provider>: [...] } })- runtime event source: provider name like
github,linear,slack,notion,jira - branch on
event.source - then branch on
event.type
- runtime event source: provider name like
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.
Authoring rules
1. Prefer one defineAgent(...) file
Default to one agent.ts per persona, exporting one defineAgent({...}) with internal branching:
if (event.source === 'cron') ...if (event.source === 'github' && event.type === '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> },
where scope maps a resource name to an absolute relayfile glob and config
passes provider-owned adapter settings through unchanged. 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: {
// Replies in-thread (writeback to /slack/channels/{id}/messages), so scope channels.
slack: { scope: { channels: '/slack/channels/**' } },
// Read-only Linear context — scope the concrete subpaths the handler reads.
linear: { scope: { projects: '/linear/projects/**', issues: '/linear/issues/**' } }
}
The full mechanics and the labelled-mirror sub-trap are in the production-correctness checklist below (§1).
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, because event.name is what the handler receives.
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.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) => {
if (event.source === 'github') {
if (event.type === 'pull_request.opened') {
// review flow
return;
}
if (event.type === 'issue_comment.created') {
// mention reply flow
return;
}
if (event.type === 'check_run.completed') {
// failed-CI reaction flow
return;
}
}
if (event.source === 'slack' && event.type === 'app_mention') {
// slack reply flow
return;
}
if (event.source === 'cron' && event.name === 'daily-triage') {
// scheduled flow
return;
}
}
});
Event-shape guidance
Use the runtime’s current event model:
- cron events:
event.source === 'cron',event.name,event.cron - provider events:
event.source === '<provider>',event.type,event.payload
Do not invent custom event wrappers when @agentworkforce/runtime already provides them.
When reading provider payloads:
- treat
event.payloadas 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.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.
Every provider in the catalog has a named client (notionClient, jiraClient,
gitlabClient, …). 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 (e.g. the created issue's URL/id).
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.
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.
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.source === 'cron' - use
event.nameto select the 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 provider source
- branch on event type
- extract target identifiers from payload
- 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 — and
that one additionally marks the whole cloud run FAILED on the teardown flush. If
you see ts: '', rule out a stuck mirror, not just a missing scope (see
setting-up-relayfile → "cloud run marked FAILED but the handler logged
runner.handler.ok").
Why github "just works" in most personas while slack doesn't: github usually
appears in triggers, and trigger paths are mounted independently of scope.
Any integration the handler only writes through (slack notifications,
linear comments on non-trigger issues) has no trigger to save it.
The labelled-mirror sub-trap — a trigger that LOOKS like it covers the write
but doesn't. A trigger mounts the watched subtree as a read mirror, and for
some providers the mirror path is display-labelled while the writeback path
is canonical bare-id. Slack is the production case: the trigger mirrors the
channel at /slack/channels/{id}__{name}/messages (channel id + __ + name),
but slackClient().post() writes to /slack/channels/{id}/messages (bare id).
The two never coincide, so a Slack trigger does NOT cover a Slack write —
the draft still lands on unmounted disk and the post is a silent no-op even
though the run logs handler.ok. This shipped as the linear-slack bug
(2026-06): the agent had a slack trigger on its board channel and still
posted into the void; the orphaned draft was recovered from the live sandbox.
github/linear are immune because their trigger and writeback paths share one
bare identifier form (/github/repos/{owner}/{repo}/...,
/linear/issues/{issueId}/...).
Rules:
- Every integration the handler writes through needs a trigger or a
non-empty
scope— except Slack (and any display-labelled mirror): a Slack WRITE always needs ascope; a trigger is never enough. Safest default: give every write-through integration an explicitscope. - Make delivery loud.
post()/reply()/dm()resolve withts: ''instead of throwing when the writeback gets no receipt (thets: ''signature above), so a dropped post still logshandler.ok. Treat an emptytsas failure (if (!result.ts) throw …) so the runtime surfaceshandler.errorinstead of a silent no-op. scope: {}does NOT work. persona-kit'sparseIntegrationConfigdiscards empty scope objects client-side before upload, so cloud's/<provider>/**provider-root fallback is unreachable from a persona.- Scope values must be strings (persona-kit
parseStringMapthrows on arrays). A value starting with/is used verbatim as a mount glob; a bare valuevunder keykbecomes/<provider>/<k>/<v>/**. - When the target is picked at deploy time (e.g. a
SLACK_CHANNELinput), scope the subtree, not the instance:
slack: {
scope: { paths: '/slack/channels/**' } // covers any picked channel, excludes DMs/users
}
A scope root must be a concrete prefix — a mid-path
*mounts NOTHING. Cloud reduces each scope to a remote root viascopedRemoteRoot(cloud/packages/core/src/relayfile/mount-script.ts): it strips a trailing/**, then discards the path entirely if any*remains (andrelayfile-mount --remote-pathonly accepts a concrete prefix, never a glob). So a*is allowed only in the final/**:// ❌ silently dropped — the mid-path `*` survives the /** strip → mounts nothing github: { scope: { paths: '/github/repos/AgentWorkforce/*/pulls/**' } } // ✅ concrete root — mounts; filter to the repos/PRs you want in the handler github: { scope: { paths: '/github/**' } }This is doubly silent: the deploy still mints a matching fs token and the path passes string validation, so nothing errors — the handler just reads an empty tree. It bit daily-ship (every digest said "No PRs merged"). Pick the broadest concrete root and narrow in code. (Platform hardening tracked in
AgentWorkforce/cloud#1986.)Verify after compiling: run
agentworkforce persona compile <dir>/persona.tsand check the generated persona.json still carriesintegrations.<p>.scopeand anyintegrations.<p>.configadapter settings. If persona-kit dropped the field, the deployed persona is silently inert or falls back to adapter defaults.Pin a test (see §6): parse
persona.integrationsthrough persona-kit'sparseIntegrationsand assert the scope survives as a non-empty map covering the writeback subtree your client uses; assert adapterconfigsurvives when a GitHub persona depends on materialization or a provider documents another provider-owned setting.
2. sandbox: true vs sandbox: false
sandbox is a top-level boolean on the persona spec
(persona-kit parse.js parseSandbox; default true when omitted).
sandbox: true (default) |
sandbox: false |
|
|---|---|---|
| Daytona box | provisioned per fire (seconds of cold start) | none — handler runs in the persona runner (ms) |
ctx.sandbox.exec() |
available | rejects (SandboxNotAvailableError) |
ctx.files.read/write |
available | unavailable — use VFS helpers (readJsonFile/writeJsonFile) against provider paths |
ctx.harness.run() |
available | still works |
| Harness CLI credentials | mounted | not mounted |
| PR-reviewer checkout / PR writeback / conflict-autofix / git workspace clone | available when capabilities declared | disabled even if declared (cloud gates them on !lightweightSandbox, deployment-trigger-delivery.ts) |
Pick sandbox: false for chat-lead / read-classify-reply personas that touch
provider data only through relayfile (e.g. the linear chat lead). Pick the
default for anything that clones repos, runs shells, or uses PR capabilities.
3. Inputs — declaration and resolution
Declare inputs in the persona spec:
inputs: {
SLACK_CHANNEL: {
description: 'Channel for review pings.',
env: 'SLACK_CHANNEL',
optional: true, // no default → unset means feature off
picker: { provider: 'slack', resource: 'channels' } // deploy-UI picker; stores channel ID
}
}
Resolution facts (verified against cloud delivery + runtime):
- Cloud does not export each input as a bare env var in the sandbox.
Input values travel inside
WORKFORCE_AGENT_CONTEXT(JSON) and surface asctx.persona.inputs(the runtime merges theinputValues/input_valuesaliases). - The conventional handler accessor checks env first (local dev), then ctx:
function input(ctx: WorkforceCtx, name: string): string | undefined {
const spec = ctx.persona?.inputSpecs?.[name];
const v = process.env[spec?.env ?? name] ?? ctx.persona?.inputs?.[name] ?? spec?.default;
return typeof v === 'string' && v.trim() ? v : undefined;
}
- An optional input with no default that gates a feature means the feature is off-by-config when undeployed — fine, but document it, and remember it compounds with §1: the feature needs the input set and the path mounted.
- Picker types observed in production:
{provider:'slack', resource:'channels'},{provider:'github', resource:'users'}. Pickers store provider IDs (e.g.C…channel IDs), not display names.
4. Harness and model selection
harness: which CLI runsctx.harness.run()prompts —'codex'or'claude'.modelmust match the harness family (e.g.gpt-5.xfor codex;claude-*for claude).harnessSettings:reasoning,timeoutSeconds,sandboxMode,workspaceWriteNetworkAccess, anddangerouslyBypassApprovalsAndSandbox: true— required for codex in cloud fires because Daytona is the trust boundary and codex's nested bubblewrap sandbox needs user namespaces Daytona doesn't allow. Say so in a comment when you set it.- Version pinning: capabilities,
integrations.<provider>.config, and other spec fields are parsed client-side by persona-kit before upload. A persona-kit older than the field you're using silently strips it (this shipped as the teamSolve-capability strip). Exact-pin@agentworkforce/persona-kit(and cli/runtime) in the repo and verify the compiled artifact carries every field you depend on.
5. Teams — teamSolve capability and team.json
A lead persona opts into team orchestration via capabilities
(cloud → packages/core/src/proactive-runtime/capabilities.ts):
"capabilities": {
"teamSolve": {
"enabled": true,
"maxMembers": 1, // default 4, hard-capped at 4
"roles": ["implementer"], // default ["lead","impl","reviewer","prober"]
"tokenBudget": 400000, // default 400000
"timeBudgetSeconds": 1800 // default 1800
}
}
Multi-member rosters bind through team.json
(cloud → packages/core/src/proactive-runtime/team-spec.ts, loaded from the
persona directory; bound via PUT /api/v1/workspaces/{id}/teams):
{
"id": "my-team", // must match the team directory name
"lead": "alice", // must reference a member name
"members": [
{ "name": "alice", "persona": "alice-persona-slug", "role": "lead" },
{ "name": "bob",
"persona": { "slug": "bob-persona-slug", "version": 2 },
"role": "implementer",
"owns": [ { "issue.labels": ["bug"] } ] }
],
"tokenBudget": 1000000,
"timeBudgetSeconds": 3600
}
Validation: unique member names and persona refs, no duplicate owns
ownership, inline persona refs rejected (Phase 1). Members resolve to
already-deployed personas in the workspace — deploy members first, then bind.
Spawning is cloud-side (launchMember); there is no in-box
ctx.team.spawn — don't write handler code that assumes one.
6. The showcase quality bar (AgentWorkforce/agents repo)
The agents repo is a public showcase. Merges get blocked on brittleness even when the code works and is approved. The bar:
- No inline base64 blobs, no
node -eone-liners, no hand-rolled shell quoting inside handlers or workflows. Extract checked-in helper scripts. - Pass data as JSON arguments (a JSON file or single JSON env/arg), never as positional shell argv that needs quoting.
- Golden tests are a merge gate. Exported pure helpers (
readPr,labelNames, allowlist deciders) get node:test coverage intests/; persona config invariants (like §1's scope) get pinned with a test proven red against the broken shape before the fix lands. - persona.json is generated from persona.ts in the agents repo (untracked since agents#24) — edit persona.ts, never the artifact.
7. onEvent handler patterns
Declare wakeups in defineAgent({...}) (triggers / schedules / watch);
branch imperatively in the handler. Hard-won guard patterns:
- First line (single-provider personas):
if (event.source !== '<provider>') return;— multi-provider handlers branch perevent.sourceinstead of returning early. - Terminal-event guards before work: approval → merge → return; green check_run → return; only then the expensive review path.
- Read materialized meta defensively. Provider projections drift — accept
both shapes when one has shipped (
author?: string | { login?: string }), and decide explicitly whether a gate fails open or closed when meta is missing (author allowlist: fail closed; skip-label check on a payload that lacks labels: fail open). Comment the choice. - Cron: discriminate with
event.name, but never write a guard that no-ops the whole persona whenevent.nameis empty — cloud's cron payload has shipped without the schedule name, turningevent.name !== 'daily'into a permanent silent no-op. Prefer "route by name when present, default to the single schedule's behavior otherwise" for single-schedule personas. - Sentinel contracts with the harness: if the handler keys behavior off
harness output (e.g. a literal
READYlast line), spell the contract out in the prompt and parse only the last line — and remember output-tail truncation preserves the end, not the start. - Log skips with reasons (
ctx.log?.('info', 'skipped', { reason })) — a silent return is indistinguishable from a delivery failure during triage.
8. Delegation — ctx.workflow.run vs ctx.harness.run
Two ways to do heavy work; pick by shape:
ctx.harness.run(args) |
ctx.workflow.run(name, args) |
|
|---|---|---|
| What | one prompt through the persona's harness CLI | a multi-step agent-relay workflow (DAG of deterministic + agent steps) |
| Returns | { output, exitCode, durationMs } directly |
{ runId, completion() }; await completion() → { output, status } |
| Use for | single coding/review task in the box | clone → implement → open-PR pipelines, multi-agent coordination |
The thin-lead pattern (linear chat lead,
references/agents/linear/agent.ts):
classify intent with a cheap harness/LLM call → reply to the user
immediately ("starting an implementation workflow…") → delegate via
ctx.workflow.run → on completion, post the result (e.g. extract the PR URL
from completion.output). The chat handler stays responsive; the workflow
carries the long work. Keep workflow definitions as checked-in files under
workflows/ (see §6) rather than assembling source strings in the handler.
9. Relayfile — how provider clients actually resolve
slackClient() / linearClient() / githubClient() / providerClient(p)
from @relayfile/relay-helpers are not HTTP clients. A write resolves a
catalog path (/slack/channels/{channelId}/messages), drops a uniquely-named
draft JSON under the mount root, and waits for the writeback worker to
replace it with a receipt. Reads (readJsonFile, .list()) read materialized
JSON from the same tree.
Consequences:
- The path must be mounted (token-scoped + daemon-watched) or the draft sits on local disk forever and the call returns silently — see §1.
- Anchor the mount root explicitly. The runner's CWD is not the mount
root (CWD
…/workforce-runtimevs mount…/workspaceshipped as a real ENOENT bug). Pass{ relayfileMountRoot: resolveMountRoot({}) }or rely on theRELAYFILE_MOUNT_ROOTenv — never on relative paths from CWD. - A returned receipt is the success signal.
result.receipt?.created/idpresent → delivered; absent after the wait → not delivered (the call does not throw for an unmounted path). If delivery is load-bearing, check the receipt and surface the failure. - Item paths (ending
.json) are direct read/write; collection paths take drafts and.list(). Encode user-supplied path segments withencodeSegment(...). - Terminal provider states (closed/merged/archived) stay readable as records with terminal status — never model them as deletions.
Production pre-merge checklist
- Every written-to integration has a trigger or a non-empty, string-valued scope (§1) — and the compiled persona.json still carries it.
sandboxmatches the capability set (§2) — noctx.sandbox.exec/ PR-capability reliance undersandbox: false.- Feature-gating inputs documented; resolution goes through a
input(ctx, …)helper, not bareprocess.env(§3). - Harness/model pair valid; persona-kit/cli/runtime pinned; compiled artifact carries every capability you declared (§4, §5).
- Tests pin the config invariants and were proven red against the broken shape (§6).
- Handler guards: source check first, terminal events early-returned,
defensive meta reads with explicit fail-open/closed choices, no
empty-
event.nameno-op gate (§7). - Writeback receipts checked where delivery matters (§9).
Anti-patterns
Avoid these:
- writing old
tiers-based personas when the repo uses flat runtime fields - putting business logic into
persona.json - declaring integrations that
agent.tsnever uses - declaring
defineAgent(...).triggers,schedules, orwatchwithout implementing branches for them - using
systemPromptas a substitute for explicit code routing - giant unstructured handlers with no helper functions
- reaching for
ctx.github/ctx.linear/ etc. — those per-provider clients no longer exist; use@relayfile/relay-helpers(or the runtime VFS helpers) - hardcoding
/<provider>/...mount paths in the handler when a@relayfile/relay-helpersclient already resolves them from the catalog - invoking external commands (
curl,gh, provider SDKs) for provider reads/writes that relay-helpers / the VFS helpers already cover via Relayfile draft writes - assuming all provider payload fields exist without validation
Deploying: lead the human from local login to a live cloud agent
Authoring isn't finished at the files — drive the deploy end to end with the
agentworkforce CLI. Run every non-interactive step yourself, hand the human
only the steps that genuinely need a browser, and narrate what each command
printed so they always know the state.
What you (the agent) can do vs. what the human must do
- Human-only (interactive browser):
agentworkforce login(OAuth sign-in) and the per-provider connect popups during deploy. You can't complete a browser OAuth flow — ask the human to run the command / finish the popup, then continue. - You run: the dry-run, the deploy itself (once the human is logged in),
deployments list, reading the printed deployment URL/status, anddestroy. (If your environment can't run the CLI at all, hand the human the exact commands below in order and tell them what each should print.)
Runbook
Check auth. If
~/.agentworkforce/active.jsonis missing (or a CLI call 401s), the human isn't logged in. Ask them to runagentworkforce login— it opens the browser tohttps://agentrelay.com, they pick a workspace, and it writes the pointer — then wait for them to confirm before continuing.--workspace <id-or-slug>skips the picker.Dry-run — you run this. Validate before any side effects:
agentworkforce deploy ./path/to/persona --mode cloud --dry-runFix any preflight error (missing
onEvent, wrong shape, an integration theagent.tslistens on butpersona.jsondoesn't declare) and re-run until clean.Deploy — you run this; the human completes any connect popups.
agentworkforce deploy ./path/to/persona --mode cloud --on-exists update- It bundles
persona.json+agent.tsand, for each provider inpersona.json.integrationsthat isn't connected yet, opens a connect flow — relay that to the human and wait for them to finish before continuing. --on-exists updateredeploys over an existing persona of the same id. Gotcha: the default iscancel, a silent no-op — if a deploy "did nothing", you wanted--on-exists update.--reconnect <provider>forces a fresh connect;--no-connectfails instead of prompting (use only when everything's already connected);--input key=valueoverrides a declared input;--detachbackgrounds the runner.
- It bundles
Confirm it's live — you run this. Capture the deployment URL/status the deploy printed, then:
agentworkforce deployments list # what's running in the workspaceReport back to the human: the deployment link, which triggers/schedules registered, and which integrations are connected. Tear down with
agentworkforce destroy ./path/to/personawhen needed.
Triggers/schedules/watch declared in defineAgent(...) register at deploy time,
so every integration the agent listens on must be connected — an unconnected
provider means its triggers never fire.
Validation checklist
Before declaring the persona done:
persona.jsonmatches the current schema shape used in examplescloudpersonas includeonEventagent.tsusesdefineAgent(...)with either a listener source:triggers, orschedules, orwatchor an intentional team-member shape likelaunchedBy: 'team-dispatcher'
- every declared trigger, schedule, or watch rule has a code path in
handler - every provider named in
agent.tslistener config is also declared inpersona.json.integrations systemPromptdescribes the role clearly- harness/model/settings fit the job
- memory config is intentional, not accidental
- the handler uses
ctx.log(...)or durable side effects clearly enough for debugging
Output contract for this skill
When creating or editing a cloud persona, return:
- the full
persona.json - the full
agent.ts - a short note explaining:
- why the chosen listener declarations belong in
defineAgent(...) - why the chosen deploy/runtime config belongs in
persona.json - why the chosen behavior belongs in
agent.ts - which current Workforce example the shape most closely follows
- why the chosen listener declarations belong in
- then drive the deploy per "Deploying" above — don't stop at the files.
Run the dry-run and deploy yourself; ask the human to run
agentworkforce loginand to finish each integration-connect popup; and finish by reporting the live deployment link, the registered triggers/schedules, and the connected integrations.