AI Workforce
See also: AI Workforce Business Process Runbooks — visual process maps for the run lifecycle, review queue decisions, delivery routing, autonomy promotion, failure recovery, and operator cadence.
The AI Workforce is an in-house agentic AI employee team that runs autonomously on schedule, produces reviewable artifacts, and delivers approved outputs to their destinations. It reduces 8-12 hours/week of repetitive operational work (content drafting, SEO audits, email design, analytics reporting, support response drafting) while maintaining brand-voice fidelity through mandatory post-filters and human oversight.
Architecture
The system has three layers:
- Rails Platform (
rails_api/) — Data models, GraphQL API, approval workflow, delivery automation, background jobs - Agent Runner (
agent_runner/) — Standalone TypeScript Cloud Run service using Claude Agent SDK that executes employee runs - Admin Dashboard (
admin_dashboard/) — Operator UI for managing employees, reviewing artifacts, and monitoring costs
Data Flow
Schedule tick (Crono) → ScheduleRunsJob → TriggerRun interaction
→ ExecuteRunJob → AgentRunnerClient dispatches to Agent Runner
→ Agent Runner loads skills + memory + MCP tools
→ query() loop with Claude Agent SDK
→ Brand-voice post-filter (second query() call)
→ Webhook POST back to Rails with artifact
→ ProcessWebhookResult creates AiArtifact + updates memory
→ Operator reviews in admin dashboard
→ ApproveArtifact → DeliverArtifactJob → destination (GitHub PR / Mailtrap / Slack / docs/ / Buffer)Employees
Seven employees are configured in shadow mode (all outputs require human approval):
| Employee | Role Key | Skills | MCP/Tools | Schedule | Output |
|---|---|---|---|---|---|
| Cori | content_drafter | crafting-page-messaging, framing-release-stories, tightening-brand-voice | GitHub MCP | Weekly (Mon 9am) | draft_post |
| Emi | lifecycle_email_designer | designing-lifecycle-messages, tightening-brand-voice | Mailtrap REST, PostHog REST | Bi-weekly (1st/15th 9am) | email_template |
| Scout | seo_auditor | inspecting-search-coverage, adding-structured-signals | GitHub MCP | Weekly (Mon 9am) | code_patch |
| Dash | analytics_reporter | instrumenting-product-metrics, mapping-conversion-events | PostHog REST, Sentry REST | Weekly (Mon 7am) | report |
| Ally | customer_support_drafter | refining-prompt-surfaces, tightening-brand-voice | Sentry REST, GitHub MCP | Daily (7am) | recommendation |
| Vera | coach_quality_auditor | tightening-brand-voice, objectuve-hallmark | None (data injected via per-run inputs) | Daily (7am) | report |
| Remy | social_publisher | orchestrating-social-rhythm, planning-editorial-arcs, tightening-brand-voice | None (data injected via per-run inputs) | Mon/Wed/Fri (9am) | social_post |
Vera — Coach Quality Auditor
Vera samples up to 25 recent CoachInteraction records — stratified per-feature so a single high-volume feature can't crowd out the rest — and produces a concise brand-voice and safety audit report. She is the first XAPP (no-DB) employee: because the Agent Runner has no database access, her working data is assembled by Rails (AiWorkforce::BuildRunInputs) and injected into the run payload at dispatch time rather than fetched by the agent herself.
CoachInteraction::FEATURES lists the 13 coach-voice surfaces Vera audits: coaching, milestones, insights, check_ins, description, welcome_back, compassionate_break, coach_synopsis, goal_draft, goal_suggestions, coach_at_risk_nudge, daily_insight_pack, weekly_digest. A sibling AUDIT_EXEMPT_FEATURES constant (moderation, feedback_summary) lists features that reach Ai::ServiceClient.chat but are deliberately excluded from her sample — moderation is content screening, feedback_summary summarizes user feedback posts for an admin dashboard, and neither is Coach voice a user actually sees. The two constants together must exactly partition AiUsageEvent::FEATURES, enforced by a bidirectional spec (spec/models/coach_interaction_spec.rb) — a new AI feature fails CI until it's consciously classified into one list or the other, rather than silently going unaudited (OBJ-1660; see the sibling AiUsageEvent::FEATURES drift gotcha for the failure pattern this guards against).
Each run, Vera assesses every message in the sample on two dimensions:
- Brand voice — Is the Coach "a coach who's also a friend"? Action-forward, brief, empowering, never generic-motivational, never a dark pattern.
- Safety — Is any message harmful, clinically/medically overreaching, shaming, or otherwise inappropriate?
Her output is an internal report artifact (internal, never user-facing) with: a one-line overall assessment, a list of flagged messages with severity (low/medium/high), and at most two concrete suggestions to tighten Coach prompts if a pattern is detected. No MCP tools are needed — all data arrives in the run inputs.
Configuration (from seed):
schedule_cron: '0 7 * * *'(daily at 7 AM)max_turns: 20autonomy_level: 'shadow'post_filter_skill: nil(output is internal — no brand-voice post-filter needed)output_kind: 'report'
Remy — Social Publisher
Remy drafts scheduled X, LinkedIn, and Facebook posts three times a week (Mon/Wed/Fri, 9am) from aggregate, de-identified sources only — shipped milestone summaries, CHANGELOG highlights, and platform-wide AdminStats counts. Like Vera, she is data-injected (no MCP tools, mcp_servers: []): AiWorkforce::BuildRunInputs#social_publisher_inputs supplies milestone_summary, changelog_entry, and aggregate_stats, explicitly excluding AdminStatsService's per-user recent_activity and monetization supporter_stats fields — no individual user is ever referenced in a public post.
Her output is a social_post artifact with payload keys channels (e.g. ["x", "linkedin", "facebook"]), content (an object keyed by channel with that channel's post text, respecting per-channel limits — X ~280 characters, LinkedIn ~3000 characters, Facebook no hard cap but the same 2-3 sentence brand-voice constraint), and link_url (required whenever facebook is in channels). Every draft passes through the tightening-brand-voice post-filter before landing in the Ops Board's Needs review column, same as Cori/Emi/Ally.
Facebook (link-preview, Route A): Facebook has no natural per-beat landing page, so link_url always points at a canonical CTA anchor from social-media-inventory.md (#features or #connect) — never an invented URL, never an in-app SPA route — tagged with ?utm_source=facebook&utm_medium=social&utm_campaign=remy-social. Facebook/Buffer scrapes og:image/og:title/og:description from that page rather than us generating an image, so every Facebook post's preview card is content-invariant (only the post text varies by beat) — an accepted tradeoff, not a bug. Click-through is measured by filtering the $autocapture / landing-page PostHog events on utm_source = facebook, reviewed on the same weekly cadence as Dash's PostHog pull below. Generated images (AiArtifact.payload.image_urls) attach to X/LinkedIn posts as Buffer assets (Route B); Facebook keeps its link-preview shape because Buffer rejects a post carrying both a link attachment and non-empty assets. Instagram remains deferred — see docs/product/og-image-facebook-scope.md for the full decision record.
social_publisher is one of two AiEmployee::SHADOW_LOCKED_ROLES (alongside Ally's customer_support_drafter) — she can never be promoted above shadow autonomy, enforced at both create and update time so the cap can't be bypassed by seeding a new record at a higher level. See Delivery Automation for how approved social_post artifacts reach Buffer.
Configuration (from seed):
schedule_cron: '0 9 * * 1,3,5'(Mon/Wed/Fri at 9 AM)max_turns: 30autonomy_level: 'shadow'(permanently locked — see Autonomy Progression)post_filter_skill: 'tightening-brand-voice'output_kind: 'social_post'
Skills
Each employee loads .claude/skills/{name}/SKILL.md files as system prompt fragments. Skills are baked into the Agent Runner Docker image at build time and define the domain expertise for each role.
MCP and REST Tool Access
- GitHub — Claude Agent SDK MCP via
npx @modelcontextprotocol/server-github(stdio subprocess) - PostHog — REST API client (
posthog_querytool) for trends, funnels, cohorts - Sentry — REST API client (
sentry_list_issues,sentry_issue_detailstools) - Mailtrap — REST API client (
mailtrap_list_templates,mailtrap_create_drafttools) - Slack — Incoming webhook (
slack_post_messagetool) - Image Gen (
image_gen) — REST client (generate_imagetool) that calls LiteLLM's OpenAI-compatiblePOST /v1/images/generationsand uploads the result through the existingDesignAssetupload path (POST /webhooks/ai-workforce/assets). Requires the LiteLLMworkforce/imageroute, defined in-repo ininfra/litellm/config.{staging,production,local}.yaml(Gemini image model via the existingGOOGLE_API_KEY) and deployed by thedeploy-litellmjobs in.github/workflows/staging.yml/production.yml. The dockerconfig.yaml(fake provider) has no image route by design, so locally the tool returns an error string and the run proceeds text-only. Generated URLs land inAiArtifact.payload.image_urls(string[]) and render in the admin Ops Board Focus overlay.
Access is scoped per employee via the mcp_servers column. An employee without a declared server cannot invoke its tools.
Memory System
Each employee has persistent structured memory (JSONB) that carries across runs:
last_run_summary— What the employee accomplished in the previous runperformance_stats— Token usage, cost, turn count historyrecent_rejections— Last 5 rejection reasons (operator + auto-rejected), capped FIFO
Memory is serialized as markdown (~2,000 token cap) and injected into the system prompt at the start of each run. After a run completes, ProcessWebhookResult upserts the memory records. When an artifact is rejected (by operator or brand-voice filter), the rejection reason is appended to recent_rejections so the employee avoids repeating mistakes.
Per-Run Inputs Channel
The Agent Runner has no database access. Employees that need dynamic data (such as Vera sampling recent CoachInteraction records) receive it via the inputs field on the POST /runs payload.
How it works:
AgentRunnerClient#build_payloadcallsAiWorkforce::BuildRunInputs.call(employee:)immediately before dispatching the run.BuildRunInputsroutes byemployee.role_keyand assembles the appropriate data hash. Any unrecognised role key returns{}.- The Agent Runner receives
inputsalongside the employee config.buildUserPromptappends it as a fenced "Provided data" section in the task prompt so the agent can reference it directly. - An absent or empty
inputshash is a no-op — all existing employees that need no dynamic data are unaffected.
Currently wired:
| Role key | Data injected |
|---|---|
coach_quality_auditor (Vera) | { coach_interactions: [...] } — up to 25 CoachInteraction records, stratified per-feature across the 13 audited coach-voice features |
social_publisher (Remy) | { milestone_summary:, changelog_entry:, aggregate_stats: } — latest shipped milestone summary, top CHANGELOG entry, and whole-platform AdminStats counts (no per-user or monetization fields) |
To add a new data-driven employee, add a when branch in AiWorkforce::BuildRunInputs#call and reference inputs keys from the employee's task_prompt.
See agent-runner.md — Per-Run Inputs Channel for the Agent Runner side of this contract.
Brand-Voice Post-Filter
Employees with a post_filter_skill (Cori, Emi, Ally) have their artifacts validated by a second query() call using the tightening-brand-voice skill before surfacing to an operator. This is a separate LLM call — not loaded into the main run's context — to prevent context dilution.
- Pass — Artifact is created as
pending, appearing in the Ops Board's Needs review column - Fail — Artifact is created with
approval_status: "auto_rejected"and a descriptive reason. It never appears in the operator's pending queue. The rejection reason is stored in the employee's memory.
Approval Workflow
Operators manage artifacts through the admin dashboard's AI Workforce Ops Board (Focus Detail overlay):
- Approve — Artifact is marked
approved, triggersDeliverArtifactJob - Approve with edits — Operator modifies the payload before approving. Edit distance (Levenshtein ratio) is tracked for performance metrics.
- Reject — Requires a reason (minimum 10 characters). Reason is stored in the employee's memory.
Delivery Automation
Approved artifacts are automatically delivered to their destination via DeliverArtifactJob:
| Output Kind | Destination | Method |
|---|---|---|
draft_post, code_patch | GitHub PR | Octokit (branch + commit + PR) |
email_template | Mailtrap sandbox | REST API (draft, never sent to real recipients) |
report | Slack channel + docs/reports/ | Webhook POST + Octokit commit |
recommendation | docs/support-responses/ | Octokit commit (never auto-sent) |
social_post | Buffer (X, LinkedIn, Facebook) | Ai::SocialPublisherClient GraphQL (api.buffer.com/graphql) — creates a scheduled draft per channel (saveToDraft: true), never immediate-published; a human still confirms in the Buffer UI. Facebook posts carry a linkAttachment (link-preview, Route A) built from the artifact's link_url — see Remy — Social Publisher |
Delivery status is tracked on each artifact (delivered_at, delivery_metadata with PR URL, Slack message ID, etc.). For social_post, delivered_at is set only once every requested channel has genuinely posted — Buffer has no idempotency key, so a channel that already succeeded on a prior attempt is never re-submitted on retry (per-channel results persist in delivery_metadata, and only the still-missing channels are retried).
Autonomy Progression
Employees start in shadow mode (all outputs require approval). Promotion is enforced as database validations:
| Level | Criteria | Enforcement |
|---|---|---|
| shadow | Default for all employees | — |
| semi_autonomous | >80% approval rate over 4+ weeks, zero rejections in last 2 weeks | validate :promotion_requirements_met on AiEmployee |
| autonomous | >95% approval rate over 8+ weeks, explicit operator confirmation | Requires promotion_confirmed_at timestamp |
Restrictions:
AiEmployee::SHADOW_LOCKED_ROLES(Ally'scustomer_support_drafterand Remy'ssocial_publisher) are permanently locked toshadow— one wrong support response or public post does outsized reputational damage. The lock is enforced on both create and update, so it can't be bypassed by seeding a new employee at a higher autonomy level.- Demotion is always allowed without criteria checks
- Operators can manually promote/demote via the admin dashboard
Budget Controls
Each employee has a configurable monthly budget cap (default $30/month):
- Pre-run enforcement —
TriggerRunchecks month-to-date spend before creating a run. Blocked if budget exceeded. - Per-run cap — Agent Runner enforces
MAX_RUN_BUDGET_USD(default $1.00) independent of monthly cap. - LiteLLM monthly cap — Third enforcement layer at the proxy level.
- Slack alerts —
BudgetAlertJobfires at 80% and 100% thresholds (deduplicated via Rails.cache).
Admin Dashboard Views
| View | Route | Purpose |
|---|---|---|
| AI Workforce (Live Ops Board) | /ai-workforce | Landing (v4.8, OBJ-1562). Four-column board (Running / Needs review / Handed to you / Shipped today) over every in-flight and recent work item, plus a live activity rail. Click a ticket to open the Focus Detail overlay for approve/edit/reject/hand-off/pause. 30s auto-refresh. /ops-board and the retired /review-queue redirect here. |
| Agents | /agents | Employee cards with status, budget bar, next run, last result. Run Now / Pause buttons. 30s auto-refresh. |
| Employee Detail | /ai-workforce/:id | Tabbed view (Overview / Runs / Memory). Approval rate chart (Chart.js 4-week rolling bar chart). Autonomy promotion controls. Edit configuration modal for skills, MCP tools, task prompt, model, schedule, and budget. |
| Run Detail | /ai-workforce/:employeeId/runs/:runId | Stat cards (turns, duration, tokens, cost). Tool call timeline. Artifact list with delivery status. |
See Admin Dashboard Guide — AI Workforce (Live Ops Board) for the full board and Focus Detail overlay walkthrough.
Employee config (skills, MCP tool access, task prompt, model, schedule, budget, post-filter skill) is editable from the Employee Detail view via the updateAiEmployee mutation — the change applies to the next run. This is what makes the seeded employees usable without a redeploy. See Admin Dashboard Guide — Employee Detail for the field-by-field walkthrough.
Agent Runner Service
The Agent Runner is a standalone TypeScript Express service deployed on Cloud Run:
- Package:
@anthropic-ai/claude-agent-sdk@0.2.104 - Port: 4001
- Endpoints:
POST /runs,GET /runs/:id/status,POST /runs/:id/cancel,GET /health - Auth: OIDC (production) or shared secret (development)
- Skills: Baked into Docker image from
.claude/skills/ - State: In-memory
runMapfor active runs (lost on restart — Rails is source of truth)
Run Lifecycle
- Rails
ExecuteRunJobdispatches viaAgentRunnerClient(Faraday, fire-and-forget) - Agent Runner accepts (202), builds system prompt (preamble + skills + memory)
query()loop runs with MCP tools and REST clients- Brand-voice post-filter (if applicable)
- Webhook POST back to Rails with result
ProcessWebhookResultcreates artifact, updates memory, records cost
Environment Variables
See rails_api/.env.example for the complete list. AI Workforce-specific variables:
| Variable | Service | Purpose |
|---|---|---|
RAILS_WEBHOOK_SECRET | Rails | HMAC secret for Agent Runner webhook |
AGENT_RUNNER_URL | Rails | Agent Runner base URL |
AGENT_RUNNER_SECRET | Rails | Shared auth secret (dev) |
AGENT_RUNNER_AUTH_MODE | Rails | secret or oidc |
GITHUB_ACCESS_TOKEN | Rails | GitHub PAT for delivery PRs |
GITHUB_TARGET_REPO | Rails | Target repo for PRs |
MAILTRAP_SANDBOX_INBOX_ID | Rails | Sandbox inbox for email delivery |
SLACK_WEBHOOK_URL | Rails | Budget alerts + report delivery |
ANTHROPIC_BASE_URL | Agent Runner | LiteLLM proxy URL |
ANTHROPIC_API_KEY | Agent Runner | LiteLLM API key |
SKILLS_DIR | Agent Runner | Path to skills directory |
MAX_RUN_BUDGET_USD | Agent Runner | Per-run token budget cap |
Local Development
# Start full stack with Agent Runner
docker-compose up --build
# Seed employees
docker-compose exec api rails db:seed
# Trigger a manual run
# Open admin dashboard → AI Workforce → click "Run Now" on any employee
# Or via GraphQL:
# POST /graphql with TriggerAiRun mutationRelated Documentation
- AI Workforce PRD — Product requirements document
- AI Employees Discovery — Vendor analysis and architecture decisions
- AI Runbook — Operational procedures
- Admin Dashboard Guide — Operator guide
- Social Media Inventory — Content strategy, channel character limits, and brand-voice rules Remy drafts against
Last updated: 2026-07-20 (PR #1639, v4.9 Phase 1b, OBJ-1536: Remy drafts Facebook link-preview posts via the Buffer GraphQL client alongside X/LinkedIn, with UTM-tagged link_url click-through measurement — see docs/product/og-image-facebook-scope.md)