ContentEngine — Technical Addendum #3
Agentic System Architecture, Tool Inventory, Workflow Orchestration & Observability
| Field | Value |
|---|---|
| Author | Alton Wells |
| Date | March 2026 |
| Status | Draft for Review |
| Parent Spec | ContentEngine Technical Specification v3 |
| Depends On | Addendum #1 (LangExtract), Addendum #2 (Content Types & SEO Validation) |
| Scope | Agent definitions, model configuration, tool specifications, workflow orchestration, human gate contracts, error handling, audit trail |
1. Purpose & Scope
This addendum defines the complete agentic system for ContentEngine: how agents are configured, what tools they use, how workflows are orchestrated across the Strategy, Content, and Production layers, how human review gates work mechanically, what happens when things fail, and how every agent decision is recorded for auditability.
1.1 What This Addendum Covers
- Agent configuration system with per-agent model selection and runtime settings
- Complete tool inventory: 28 tools across shared, strategy, content, and production categories
- Strategy Layer workflow: orchestration of Competitive Intelligence, Search Landscape, and Content Strategy agents
- Content Layer workflow: Writer → Editor revision loop with step budget management
- Production Layer workflow: human review gate, cleanup, SEO validation, publishing
- Human gate contracts: suspend/resume event schemas for all three human touchpoints
- Post-publish pipeline: event-driven job chain specification
- Error handling: maxSteps policy, failure modes, escalation paths
- Audit trail schema and logging strategy
- Brand voice and prompt architecture
1.2 What This Addendum Does Not Cover
- Filesystem-as-context architecture (deferred per parent spec Section 11)
- Agent prompt engineering details (separate addendum — this addendum defines the structure, not the prose)
- Hierarchical summary generation logic (separate addendum)
- Graph relationship builder logic (separate addendum)
- UI/UX specifications for the calendar, editor, or dashboard views
1.3 Architectural Principle: Deterministic Orchestration, Agentic Execution
ContentEngine uses workflows for orchestration and agents for execution. The workflow defines the fixed sequence: always run Competitive Intel, then Search Landscape, then feed both to Strategy. The agents within each step decide how to accomplish their task — which tools to call, in what order, how many times. This is the correct separation. Workflows own the "what happens next." Agents own the "how do I do this step."
This means we do not use a supervisor/sub-agent pattern for the Strategy Layer. The Strategy Agent does not dynamically decide whether to run competitive analysis — it always receives competitive analysis output as input. The workflow guarantees this. Agent autonomy is scoped to tool selection and reasoning within a step, not to workflow routing.
2. Agent Configuration System
2.1 Design Principle
Every agent's model is configurable at runtime via a settings table. No model ID is hardcoded in agent definitions. This allows the team to swap models without redeploying code — useful for cost optimization, testing new model releases, and per-agent tuning.
2.2 Configuration Schema
1CREATE TABLE agent_config (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 agent_id VARCHAR(50) NOT NULL UNIQUE,
4 display_name VARCHAR(100) NOT NULL,
5 model_id VARCHAR(100) NOT NULL,
6 max_steps INTEGER NOT NULL DEFAULT 10,
7 temperature FLOAT DEFAULT 0.7,
8 category VARCHAR(30) NOT NULL, -- 'strategy', 'content', 'production'
9 is_active BOOLEAN DEFAULT true,
10 notes TEXT,
11 updated_at TIMESTAMP DEFAULT now(),
12 updated_by VARCHAR(50) DEFAULT 'system'
13);
14
15CREATE INDEX idx_agent_config_id ON agent_config(agent_id);2.3 Default Configuration
| Agent ID | Display Name | Default Model | Default maxSteps | Category | Temperature |
|---|---|---|---|---|---|
competitive-intelligence | Competitive Intelligence | anthropic/claude-sonnet-4-20250514 | 12 | strategy | 0.5 |
search-landscape | Search Landscape | anthropic/claude-sonnet-4-20250514 | 10 | strategy | 0.5 |
content-strategy | Content Strategy | anthropic/claude-sonnet-4-20250514 | 20 | strategy | 0.7 |
content-brief | Content Brief | anthropic/claude-sonnet-4-20250514 | 15 | content | 0.6 |
writer | Writer | anthropic/claude-sonnet-4-20250514 | 12 | content | 0.8 |
editor | Editor | anthropic/claude-sonnet-4-20250514 | 10 | content | 0.4 |
final-cleanup | Final Cleanup | anthropic/claude-sonnet-4-20250514 | 6 | production | 0.2 |
publishing | Publishing | anthropic/claude-sonnet-4-20250514 | 15 | production | 0.1 |
seo-autofix | SEO Auto-Fix | anthropic/claude-sonnet-4-20250514 | 8 | production | 0.3 |
Temperature rationale: Strategy agents are moderate (need creative planning but grounded in data). The Writer is highest (creative generation). The Editor is low (precision, not creativity). Production agents are near-zero (mechanical execution).
2.4 Agent Instantiation Pattern
Every agent resolves its model and parameters from the config table at invocation time, not at import time. This ensures config changes take effect immediately without restart.
1import { Agent } from "@mastra/core/agent";
2
3async function createConfiguredAgent(
4 agentId: string,
5 instructions: string | (() => Promise<string>),
6 tools: Record<string, Tool>
7): Promise<Agent> {
8 const config = await db.getAgentConfig(agentId);
9
10 return new Agent({
11 id: agentId,
12 model: config.model_id,
13 instructions,
14 tools,
15 maxSteps: config.max_steps,
16 });
17}2.5 Settings UI Requirements
The agent configuration is exposed in the Strategy Settings view (parent spec Section 7.3). The UI provides a table of all agents with editable model, maxSteps, and temperature fields. Changes are saved to agent_config and take effect on the next agent invocation. The UI also displays last-run metadata per agent (timestamp, token usage, step count) pulled from the audit trail to inform tuning decisions.
3. Tool Inventory
Tools are the interface between agents and the outside world. Each tool has a defined input schema (Zod), output schema, and a single clear purpose. Tools are grouped into four categories: shared (used by multiple agents), strategy-specific, content-specific, and production-specific.
3.1 Shared Tools
These tools are available to multiple agents. The underlying implementation is shared; each agent receives the same tool instance.
Tool S1: readDomainSummary
Used by: Competitive Intelligence, Content Strategy
| Field | Value |
|---|---|
| Purpose | Returns the Level 0 domain summary from the hierarchical summary tree. ~500 tokens of high-level competitive landscape. |
| Input | { scope: "ours" | "competitor" | "combined" } |
| Output | { summary_text: string, metrics_snapshot: object, generated_at: Date } |
| Data Source | domain_summaries table |
| Notes | If no summary exists for the requested scope, returns a structured error. Agent should not retry — missing summaries indicate the summary generation pipeline hasn't run yet. |
Tool S2: readClusterSummaries
Used by: Competitive Intelligence, Content Strategy
| Field | Value |
|---|---|
| Purpose | Returns Level 1 cluster summaries. Each ~300 tokens with performance data, gap analysis, and competitor comparison. |
| Input | { clusterId?: string, scope?: "ours" | "competitor" | "combined", limit?: number } |
| Output | { summaries: Array<{ cluster_id, cluster_name, summary_text, page_count, performance, gap_analysis, competitor_comparison, generated_at }> } |
| Data Source | cluster_summaries table |
| Notes | Without clusterId, returns all clusters for the given scope. Default limit: 20. |
Tool S3: readPageSummaries
Used by: Content Strategy, Content Brief
| Field | Value |
|---|---|
| Purpose | Returns Level 2 page summaries. Each ~150 tokens with topic coverage, keywords, and quality score. |
| Input | { clusterId?: string, pageType?: "ours" | "competitor", keyword?: string, limit?: number } |
| Output | { summaries: Array<{ page_id, url, page_type, summary_text, topics, keywords, quality_score, generated_at }> } |
| Data Source | page_summaries table |
| Notes | The keyword filter does a text search within the keywords JSONB field. Default limit: 30. |
Tool S4: queryExtractions
Used by: Content Strategy, Content Brief, Writer, Editor
| Field | Value |
|---|---|
| Purpose | Queries Level 3 entity data from any extraction table. The universal structured data access tool. |
| Input | { source: "competitor" | "ours" | "serp" | "ai_overview" | "brand_voice", extractionClass: string, keyword?: string, pageId?: string, since?: Date, limit?: number } |
| Output | { extractions: Array<{ id, extraction_class, extraction_text, attributes, source_location, page_url?, created_at }>, total_count: number } |
| Data Source | competitor_extractions, our_page_extractions, serp_extractions, brand_voice_extractions (routed by source) |
| Notes | The keyword filter searches within extraction_text and attributes JSONB. Default limit: 50. This is the most frequently called tool across all agents. |
Tool S5: traverseContentGraph
Used by: Competitive Intelligence, Content Strategy, Content Brief, Writer
| Field | Value |
|---|---|
| Purpose | Walks the content relationship graph following specified edge types. Returns connected nodes with relationship metadata. |
| Input | { startNodeType: string, startNodeId?: string, edgeTypes: string[], direction?: "outbound" | "inbound" | "both", maxDepth?: number, limit?: number } |
| Output | { nodes: Array<{ node_type, node_id, relationship_type, relationship_metadata, depth, connected_to }>, edge_count: number } |
| Data Source | content_relationships table |
| Notes | startNodeId is optional — when omitted with a startNodeType, the tool returns all edges of the specified types for that node type (e.g., all gap edges). maxDepth defaults to 2. limit defaults to 100. Results are sorted by confidence score descending. |
While this is a single flexible tool, agents receive curated guidance in their instructions about which edge types are relevant to their task. The Competitive Intelligence agent is told to use competes_with, outperforms, gap, and same_topic_as. The Writer is told to use links_to, should_link_to, and covers_topic. This constrains behavior without requiring separate tool implementations.
Tool S6: webSearch
Used by: Competitive Intelligence, Search Landscape, Content Strategy, Content Brief, Writer, Editor
| Field | Value |
|---|---|
| Purpose | Live web search for validation, research, and fresh intelligence. |
| Input | { query: string, maxResults?: number } |
| Output | { results: Array<{ title, url, snippet, published_date? }> } |
| Data Source | Firecrawl search API |
| Notes | maxResults defaults to 5, maximum 10. Rate limited to 30 requests per minute across all agents. The tool wrapper handles rate limiting and returns a structured error if the limit is hit. Agents should not retry immediately — the rate limit resets within 2 minutes. |
Tool S7: queryContentPlan
Used by: Content Strategy, Content Brief
| Field | Value |
|---|---|
| Purpose | Returns content plan items from the calendar, filtered by status, type, and date range. |
| Input | { status?: string[], contentType?: string[], dateRange?: { start: Date, end: Date }, source?: "ai_generated" | "human_added", limit?: number } |
| Output | { items: Array<ContentPlanItem>, total_count: number } |
| Data Source | content_plan_items table |
| Notes | Without filters, returns all non-published items. Default limit: 50. |
Tool S8: readContentStrategy
Used by: Content Strategy, Content Brief, Writer (via brief context)
| Field | Value |
|---|---|
| Purpose | Returns the active content strategy directives. |
| Input | {} (no parameters — always returns the active strategy) |
| Output | { id, name, description, target_audience, brand_voice_guidelines, content_pillars, priorities, active } |
| Data Source | content_strategy table (filtered by active: true) |
| Notes | If no active strategy exists, returns structured error. System should always have exactly one active strategy. |
3.2 Strategy Layer Tools
These tools are specific to Strategy Layer agents and not shared with Content or Production agents.
Tool ST1: queryCompetitorChanges
Used by: Competitive Intelligence
| Field | Value |
|---|---|
| Purpose | Returns recent content changes detected across competitor sites. |
| Input | { competitorId?: string, since: Date, changeType?: "new" | "updated" | "removed", limit?: number } |
| Output | { changes: Array<{ id, competitor_name, page_url, change_type, detected_at, diff_summary }>, total_count: number } |
| Data Source | competitor_changes table joined with competitors |
| Notes | Default limit: 50. since is required — no unbounded historical queries. For the daily digest, since is set to 24 hours ago. For the weekly full analysis, since is set to 7 days ago. |
Tool ST2: queryKeywordPerformance
Used by: Search Landscape
| Field | Value |
|---|---|
| Purpose | Returns our ranking data with change deltas over a specified period. |
| Input | { keywordId?: string, clusterId?: string, minPositionChange?: number, dateRange: { start: Date, end: Date }, limit?: number } |
| Output | { rankings: Array<{ keyword, url, current_position, previous_position, position_change, impressions, clicks, ctr }> } |
| Data Source | our_page_performance joined with keywords |
| Notes | minPositionChange filters for significant movers only (e.g., minPositionChange: 3 returns only keywords that moved 3+ positions). Default limit: 100. |
Tool ST3: querySerpExtractions
Used by: Search Landscape
| Field | Value |
|---|---|
| Purpose | Returns structured SERP feature data for tracked keywords. |
| Input | { keywordId: string, extractionClass?: string, snapshotDate?: Date } |
| Output | { extractions: Array<{ extraction_class, extraction_text, attributes, snapshot_date }> } |
| Data Source | serp_extractions joined with serp_snapshots |
| Notes | Without snapshotDate, returns the most recent snapshot. |
Tool ST4: queryAiOverviewTracking
Used by: Search Landscape
| Field | Value |
|---|---|
| Purpose | Returns AI Overview presence and citation status for tracked keywords. |
| Input | { keywordId?: string, ourSiteCited?: boolean, since?: Date, limit?: number } |
| Output | { overviews: Array<{ keyword, detected_at, our_site_cited, cited_sources, summary_text }> } |
| Data Source | ai_overview_tracking |
| Notes | ourSiteCited filter is the primary alert mechanism — the Search Landscape agent checks for keywords where AI Overviews exist but don't cite us. |
Tool ST5: semrushKeywordResearch
Used by: Search Landscape, Content Strategy
| Field | Value |
|---|---|
| Purpose | Fetches fresh keyword data from the Semrush API. |
| Input | { keywords: string[], market?: string } |
| Output | { keywords: Array<{ keyword, search_volume, difficulty, cpc, intent, trend }> } |
| Data Source | Semrush REST API |
| Notes | Market defaults to "us". Rate limited by Semrush plan (Business: 5,000 requests/day). Results are cached in the keywords table — the tool checks cache first and only calls the API for stale or missing data (stale = older than 7 days). |
Tool ST6: gscPerformanceQuery
Used by: Search Landscape
| Field | Value |
|---|---|
| Purpose | Queries real-time data from Google Search Console. |
| Input | { urls?: string[], queries?: string[], dateRange: { start: Date, end: Date }, dimensions?: string[] } |
| Output | { rows: Array<{ url?, query?, impressions, clicks, ctr, avg_position, date? }> } |
| Data Source | Google Search Console API (OAuth2) |
| Notes | dimensions defaults to ["query", "page"]. Maximum date range: 16 months (GSC API limit). Results are written to our_page_performance table after retrieval — this tool both queries and caches. |
Tool ST7: addContentPlanItem
Used by: Content Strategy
| Field | Value |
|---|---|
| Purpose | Writes a new item to the content calendar. |
| Input | ContentPlanItem schema (title, targetKeyword, contentType, priority, scheduledDate, rationale, etc.) |
| Output | { id: string, created: boolean } |
| Data Source | content_plan_items table (INSERT) |
| Notes | All items created by this tool are automatically tagged with source: "ai_generated". The tool validates that the targetKeyword exists in the keywords table and that no duplicate item exists for the same keyword + content type combination. |
Tool ST8: updateContentPlanItem
Used by: Content Strategy
| Field | Value |
|---|---|
| Purpose | Modifies an existing content plan item. |
| Input | { id: string, updates: Partial<ContentPlanItem> } |
| Output | { id: string, updated: boolean } |
| Data Source | content_plan_items table (UPDATE) |
| Notes | Cannot update items with status published. Cannot change source field. All updates are logged in the audit trail. |
3.3 Content Layer Tools
Tool C1: loadApprovedBrief
Used by: Writer (via workflow step, not direct agent tool)
| Field | Value |
|---|---|
| Purpose | Loads an approved content brief with all associated data for the Writer agent's context. |
| Input | { briefId: string } |
| Output | { brief: ContentBrief, brandVoice: BrandVoiceExtractions, strategy: ContentStrategy } |
| Data Source | content_briefs + brand_voice_extractions + content_strategy |
| Notes | This is a workflow step tool, not an agent-callable tool. It assembles the complete context package that becomes the Writer agent's dynamic instructions. The brief, brand voice, and strategy are loaded together in a single operation to minimize database round-trips. |
Tool C2: queryOurPages
Used by: Editor, Content Brief
| Field | Value |
|---|---|
| Purpose | Queries our published content inventory. |
| Input | { url?: string, keyword?: string, contentType?: string, status?: string, limit?: number } |
| Output | { pages: Array<{ id, url, title, content_type, status, published_at, word_count }> } |
| Data Source | our_pages table |
| Notes | Used by the Editor to verify internal link targets resolve to real published pages. Used by the Content Brief agent to check what we already cover. |
Tool C3: verifyInternalLinks
Used by: Editor, Final Cleanup
| Field | Value |
|---|---|
| Purpose | Validates that all internal links in a content body resolve to published pages. |
| Input | { contentBody: string } |
| Output | { links: Array<{ url, anchor_text, exists: boolean, page_title?: string }>, broken_count: number, valid_count: number } |
| Data Source | Parses markdown links from contentBody, checks each against our_pages table |
| Notes | Returns the full link inventory with validation status. The Editor uses this to flag broken links as critical edits. The Final Cleanup agent uses it for the post-human-edit verification pass. |
3.4 Production Layer Tools
Tool P1: formatForCms
Used by: Publishing
| Field | Value |
|---|---|
| Purpose | Converts the final markdown draft into the format expected by the Supabase content tables. |
| Input | { contentBody: string, metadata: { title, slug, contentType, authorId, targetKeyword, secondaryKeywords } } |
| Output | { formatted: { html_body: string, raw_text: string, meta_title: string, meta_description: string, schema_json: string, slug: string } } |
| Data Source | None (transformation logic) |
| Notes | Generates JSON-LD schema markup per the content type mapping defined in Addendum #2 Section 4. Converts markdown to HTML. Extracts raw text for the raw_text field. Validates slug format. |
Tool P2: uploadToCms
Used by: Publishing
| Field | Value |
|---|---|
| Purpose | Writes the formatted content to the Supabase content tables. |
| Input | { formatted: FormattedContent (output of formatForCms), planItemId: string, draftId: string } |
| Output | { pageId: string, url: string, published: boolean, publishedAt: Date } |
| Data Source | our_pages table (INSERT or UPDATE for refreshes) + our_page_seo table |
| Notes | For new content, creates a new row. For refreshes, updates the existing row and preserves published_at (original date) while updating last_updated. Updates content_plan_items status to published. Updates content_drafts status to published. |
Tool P3: setMetadata
Used by: Publishing
| Field | Value |
|---|---|
| Purpose | Sets SEO metadata fields on the published page record. |
| Input | { pageId: string, metaTitle: string, metaDescription: string, canonicalUrl: string, schemaMarkup: string, openGraph: object, twitterCard: object } |
| Output | { pageId: string, updated: boolean } |
| Data Source | our_page_seo table (UPDATE) |
| Notes | Separated from uploadToCms because metadata may be adjusted by the SEO auto-fix agent independently of the content body. |
Tool P4: pingIndexingApi
Used by: Publishing
| Field | Value |
|---|---|
| Purpose | Submits the published URL to Google's Indexing API and/or IndexNow for fast crawling. |
| Input | { url: string, action: "URL_UPDATED" | "URL_DELETED" } |
| Output | { indexed: boolean, response: string } |
| Data Source | Google Indexing API / IndexNow API |
| Notes | Tries IndexNow first (simpler, broader search engine coverage). Falls back to Google Indexing API if available. Logs the response for the post-publish monitoring check at 24 hours. |
Tool P5: triggerPostPublishPipeline
Used by: Publishing
| Field | Value |
|---|---|
| Purpose | Fires the post-publish event that kicks off the extraction → summary → graph → linking chain. |
| Input | { pageId: string, url: string, isRefresh: boolean } |
| Output | { triggered: boolean, jobId: string } |
| Data Source | Event emitter → Trigger.dev job queue |
| Notes | This tool does not wait for the pipeline to complete. It fires the event and returns immediately. The pipeline runs asynchronously. See Section 8 for pipeline specification. |
Tool P6: schedulePostPublishMonitoring
Used by: Publishing
| Field | Value |
|---|---|
| Purpose | Schedules the 24h/7d/30d monitoring checks for a newly published page. |
| Input | { pageId: string, url: string, targetKeyword: string } |
| Output | { scheduled: boolean, monitoringJobs: Array<{ checkType, scheduledAt }> } |
| Data Source | Trigger.dev scheduled jobs |
| Notes | Creates three scheduled jobs: 24h (index verification via GSC), 7d (initial ranking check), 30d (full performance review). Each job writes results to our_page_performance and flags underperformers for the refresh queue. |
Tool P7: logToAuditTrail
Used by: Publishing (and available to all agents via workflow context)
| Field | Value |
|---|---|
| Purpose | Writes a structured entry to the audit trail. |
| Input | { agentId: string, workflowRunId: string, action: string, details: object } |
| Output | { logged: boolean } |
| Data Source | agent_audit_log table (INSERT) |
| Notes | See Section 10 for the full audit trail specification. This tool is called explicitly by the Publishing Agent at each pipeline step. Other agents have their tool calls logged automatically by the workflow engine (see Section 10.2). |
3.5 Tool Count Summary
| Category | Count | Tools |
|---|---|---|
| Shared | 8 | S1–S8 |
| Strategy | 8 | ST1–ST8 |
| Content | 3 | C1–C3 |
| Production | 7 | P1–P7 |
| Total | 26 |
3.6 Tool-to-Agent Assignment Matrix
| Tool | Comp. Intel | Search Land. | Content Strat. | Brief | Writer | Editor | Cleanup | Publishing | SEO Fix |
|---|---|---|---|---|---|---|---|---|---|
| S1 readDomainSummary | ✓ | ✓ | |||||||
| S2 readClusterSummaries | ✓ | ✓ | |||||||
| S3 readPageSummaries | ✓ | ✓ | |||||||
| S4 queryExtractions | ✓ | ✓ | ✓ | ||||||
| S5 traverseContentGraph | ✓ | ✓ | ✓ | ✓ | |||||
| S6 webSearch | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |||
| S7 queryContentPlan | ✓ | ✓ | |||||||
| S8 readContentStrategy | ✓ | ||||||||
| ST1 queryCompetitorChanges | ✓ | ||||||||
| ST2 queryKeywordPerformance | ✓ | ||||||||
| ST3 querySerpExtractions | ✓ | ||||||||
| ST4 queryAiOverviewTracking | ✓ | ||||||||
| ST5 semrushKeywordResearch | ✓ | ✓ | |||||||
| ST6 gscPerformanceQuery | ✓ | ||||||||
| ST7 addContentPlanItem | ✓ | ||||||||
| ST8 updateContentPlanItem | ✓ | ||||||||
| C2 queryOurPages | ✓ | ✓ | |||||||
| C3 verifyInternalLinks | ✓ | ✓ | |||||||
| P1 formatForCms | ✓ | ||||||||
| P2 uploadToCms | ✓ | ||||||||
| P3 setMetadata | ✓ | ✓ | |||||||
| P4 pingIndexingApi | ✓ | ||||||||
| P5 triggerPostPublishPipeline | ✓ | ||||||||
| P6 scheduleMonitoring | ✓ | ||||||||
| P7 logToAuditTrail | ✓ |
Design principle: each agent receives only the tools it needs. A smaller tool surface means fewer irrelevant tool calls, lower token usage, and easier debugging. The Writer agent has 4 tools. The Publishing agent has 7 tools. The Content Strategy agent has 10 tools (the most complex decision-maker).
4. Strategy Layer Workflow
4.1 Workflow Definition
The Strategy Layer is a single Mastra workflow containing three sequential agent steps plus a human review gate. The workflow is triggered either by a scheduled job (weekly) or by a human clicking "Generate Plan" in the calendar UI.
1const strategyLayerWorkflow = createWorkflow({
2 id: "strategy-layer",
3 inputSchema: z.object({
4 triggerType: z.enum(["scheduled", "manual"]),
5 triggeredBy: z.string().optional(),
6 }),
7 outputSchema: z.object({
8 planItemIds: z.array(z.string()),
9 strategyNotes: z.string(),
10 }),
11})
12 .then(competitiveIntelStep)
13 .then(searchLandscapeStep)
14 .then(contentStrategyStep)
15 .then(saveAndSuspendForReviewStep)
16 .commit();4.2 Step 1: Competitive Intelligence Agent
The Competitive Intelligence agent runs first. Its output becomes part of the Content Strategy agent's input context.
Agent invocation within step:
1const competitiveIntelStep = createStep({
2 id: "competitive-intel",
3 inputSchema: z.object({
4 triggerType: z.string(),
5 }),
6 outputSchema: CompetitiveIntelOutput,
7 execute: async ({ inputData }) => {
8 const agent = await createConfiguredAgent(
9 "competitive-intelligence",
10 competitiveIntelInstructions,
11 { readDomainSummary, readClusterSummaries, queryCompetitorChanges,
12 queryExtractions, traverseContentGraph, webSearch }
13 );
14 const result = await agent.generate([
15 { role: "user", content: buildCompetitiveIntelPrompt(inputData.triggerType) }
16 ]);
17 return result.object; // structured output via Zod
18 },
19});The agent's tool call pattern (expected, not enforced):
readDomainSummary({ scope: "combined" })— get the big picture (~500 tokens)queryCompetitorChanges({ since: 7daysAgo })— what's new (~variable)readClusterSummaries({ scope: "competitor" })— competitor coverage depth (~3,000 tokens)queryExtractions({ source: "competitor", ... })— drill into specific changes (~variable)traverseContentGraph({ edgeTypes: ["gap", "outperforms"] })— structural threats (~variable)webSearch(...)— validate and enrich findings as needed
Output: CompetitiveIntelOutput as defined in parent spec Section 4.1. Stored in workflow state for the Content Strategy step.
4.3 Step 2: Search Landscape Agent
Runs after Competitive Intelligence. Independent analysis — does not consume CompetitiveIntel output.
Tool call pattern:
queryKeywordPerformance({ dateRange: last30days, minPositionChange: 3 })— significant moversqueryAiOverviewTracking({ since: 7daysAgo })— AI Overview changesquerySerpExtractions(...)— SERP feature details for priority keywordsgscPerformanceQuery(...)— real-time performance datasemrushKeywordResearch(...)— fresh data for emerging keywords
Output: SearchLandscapeOutput as defined in parent spec Section 4.2. Stored in workflow state.
4.4 Step 3: Content Strategy Agent
The brain. Receives both prior agent outputs via workflow state plus direct tool access for deeper investigation.
Input context assembly (performed in the workflow step before agent invocation):
1const contentStrategyStep = createStep({
2 id: "content-strategy",
3 inputSchema: z.object({
4 competitiveIntel: CompetitiveIntelOutput,
5 searchLandscape: SearchLandscapeOutput,
6 }),
7 outputSchema: ContentPlanOutput,
8 execute: async ({ inputData }) => {
9 const agent = await createConfiguredAgent(
10 "content-strategy",
11 await buildStrategyInstructions(),
12 { readContentStrategy, readDomainSummary, readClusterSummaries,
13 readPageSummaries, queryExtractions, queryContentPlan,
14 traverseContentGraph, webSearch, addContentPlanItem,
15 updateContentPlanItem }
16 );
17
18 const result = await agent.generate([
19 {
20 role: "user",
21 content: `COMPETITIVE INTELLIGENCE REPORT:\n${JSON.stringify(inputData.competitiveIntel)}\n\n` +
22 `SEARCH LANDSCAPE REPORT:\n${JSON.stringify(inputData.searchLandscape)}\n\n` +
23 `Generate a prioritized content plan for the next production cycle.`
24 }
25 ]);
26 return result.object;
27 },
28});The Content Strategy agent's expected context budget (~14,000 tokens total, as specified in parent spec Section 4.3) is consumed as:
| Source | Tokens | Method |
|---|---|---|
| Active strategy directives | ~500 | readContentStrategy() |
| Domain summary | ~500 | readDomainSummary() |
| Competitive Intel output | ~2,000 | Passed via workflow state |
| Search Landscape output | ~2,000 | Passed via workflow state |
| Cluster summaries | ~3,000 | readClusterSummaries() |
| Current calendar | ~1,000 | queryContentPlan() |
| Graph edges | ~1,500 | traverseContentGraph() |
| Drill-down page summaries | ~2,000 | readPageSummaries() (selective) |
| Entity-level extractions | ~1,500 | queryExtractions() (selective) |
4.5 Step 4: Save & Suspend for Human Review
After the Content Strategy agent runs, its output (new plan items) is persisted to content_plan_items and the workflow suspends.
1const saveAndSuspendForReviewStep = createStep({
2 id: "save-and-suspend",
3 inputSchema: ContentPlanOutput,
4 outputSchema: z.object({ planItemIds: z.array(z.string()) }),
5 execute: async ({ inputData, suspend }) => {
6 // Write plan items to database
7 const ids = await db.batchInsertContentPlanItems(
8 inputData.planItems.map(item => ({
9 ...item,
10 status: "planned",
11 source: "ai_generated",
12 }))
13 );
14
15 // Notify human
16 await notifications.send({
17 channel: "slack",
18 message: `ContentEngine generated ${ids.length} new content plan items. Review at [calendar URL]`,
19 });
20
21 // Suspend workflow — waits for human to resume via API
22 const reviewResult = await suspend({
23 type: "calendar-review",
24 planItemIds: ids,
25 message: "Awaiting human review of content calendar",
26 });
27
28 // Human has approved — reviewResult contains approved item IDs
29 return { planItemIds: reviewResult.approvedItemIds };
30 },
31});See Section 7.1 for the full human gate contract.
5. Content Layer Workflow
5.1 Workflow Definition
The Content Layer workflow runs once per approved content plan item. It is triggered when a brief is approved by the human reviewer. The workflow manages the Writer → Editor → revision loop with a maximum of 2 revision cycles.
1const contentLayerWorkflow = createWorkflow({
2 id: "content-layer",
3 inputSchema: z.object({
4 briefId: z.string(),
5 planItemId: z.string(),
6 }),
7 outputSchema: z.object({
8 draftId: z.string(),
9 seoScore: z.string(),
10 }),
11})
12 .then(loadBriefStep)
13 .then(writerStep)
14 .then(editorStep)
15 .then(revisionDecisionStep) // branches based on editor output
16 .then(saveDraftStep)
17 .commit();5.2 Brief Generation Sub-Workflow
Before the Content Layer runs, each approved plan item goes through brief generation. This is a separate workflow that suspends for human brief approval.
1const briefGenerationWorkflow = createWorkflow({
2 id: "brief-generation",
3 inputSchema: z.object({ planItemId: z.string() }),
4 outputSchema: z.object({ briefId: z.string() }),
5})
6 .then(generateBriefStep)
7 .then(saveBriefAndSuspendStep) // suspends for human approval
8 .commit();The Content Brief agent receives: approved plan item data, competitor extractions for the target keyword, graph edges, page summaries, brand voice extractions, and web search results. Output is the ContentBriefOutput schema defined in parent spec Section 4.4.
5.3 Writer Agent Step
The Writer receives the full context package assembled by loadBriefStep:
- Approved brief (outline, word counts, keyword targets, competitor gaps)
- Brand voice extractions (tone markers, vocabulary, patterns)
- Active strategy directives (pillars, audience, priorities)
This context is injected into the agent's dynamic instructions at invocation time. The Writer agent's tools are deliberately minimal — most of its context comes from the brief, not from live queries:
| Tool | Purpose |
|---|---|
webSearch | Real-time fact verification during writing |
queryExtractions | Check consistency with existing content |
traverseContentGraph | Find additional internal linking opportunities |
maxSteps: 12. The Writer typically uses 2-4 tool calls for research/verification plus 1 final generation call. The buffer accounts for longer pieces that require multiple fact-check cycles.
Output: Full markdown content body with frontmatter, internal links, external links, and [IMAGE: ...] placement markers.
5.4 Editor Agent Step
The Editor receives the Writer's draft plus the original brief and brand voice data.
| Tool | Purpose |
|---|---|
queryOurPages | Verify internal link targets resolve |
verifyInternalLinks | Batch link validation |
webSearch | Fact-check claims and statistics |
maxSteps: 10. The Editor typically uses 2-3 tool calls (link verification, fact-checking) plus 1 analysis/output call.
Output: EditorOutput as defined in parent spec Section 5.2 — overall assessment, edit list, voice consistency score, readability score, and optionally a revised draft.
5.5 Revision Loop
The revision decision step implements a bounded loop:
1const revisionDecisionStep = createStep({
2 id: "revision-decision",
3 inputSchema: z.object({
4 editorOutput: EditorOutput,
5 revisionCount: z.number(),
6 originalBrief: ContentBrief,
7 brandVoice: BrandVoiceExtractions,
8 }),
9 outputSchema: z.object({
10 finalDraft: z.string(),
11 totalRevisions: z.number(),
12 }),
13 execute: async ({ inputData }) => {
14 const { editorOutput, revisionCount, originalBrief, brandVoice } = inputData;
15
16 if (editorOutput.overallAssessment === "pass" || revisionCount >= 2) {
17 // Accept the draft — either it passed or we've hit max revisions
18 return {
19 finalDraft: editorOutput.revisedContent || inputData.currentDraft,
20 totalRevisions: revisionCount,
21 };
22 }
23
24 // Revision needed — send back to Writer with edit context
25 const writerAgent = await createConfiguredAgent("writer", ...);
26 const revisedDraft = await writerAgent.generate([
27 { role: "user", content: buildRevisionPrompt(editorOutput.edits, originalBrief) }
28 ]);
29
30 // Run through Editor again
31 const editorAgent = await createConfiguredAgent("editor", ...);
32 const newEditorOutput = await editorAgent.generate([
33 { role: "user", content: buildEditorPrompt(revisedDraft.text, originalBrief, brandVoice) }
34 ]);
35
36 // Recursive check (bounded by revisionCount)
37 return executeRevisionDecision({
38 editorOutput: newEditorOutput.object,
39 revisionCount: revisionCount + 1,
40 originalBrief,
41 brandVoice,
42 currentDraft: revisedDraft.text,
43 });
44 },
45});Maximum 2 revision cycles. After 2 cycles, the draft proceeds to human review regardless. The human reviewer sees the Editor's notes and can make corrections that the agents missed. This prevents infinite loops and respects the design principle that humans catch what agents miss.
6. Production Layer Workflow
6.1 Workflow Definition
The Production Layer handles human review, cleanup, SEO validation, and publishing. It contains the third and final human gate.
1const productionLayerWorkflow = createWorkflow({
2 id: "production-layer",
3 inputSchema: z.object({
4 draftId: z.string(),
5 planItemId: z.string(),
6 briefId: z.string(),
7 }),
8 outputSchema: z.object({
9 pageId: z.string().optional(),
10 published: z.boolean(),
11 failureReason: z.string().optional(),
12 }),
13})
14 .then(suspendForHumanReviewStep)
15 .then(finalCleanupStep)
16 .then(seoValidationStep)
17 .then(seoFixLoopStep)
18 .then(publishStep)
19 .commit();6.2 Human Review Gate
The workflow suspends and presents the draft in the editor interface (parent spec Section 7.2). The human reviewer performs all actions specified in parent spec Section 6.1: reading, adding experience, placing images, editing voice, fact-checking, and approving or rejecting.
1const suspendForHumanReviewStep = createStep({
2 id: "human-review",
3 execute: async ({ inputData, suspend }) => {
4 const draft = await db.getDraft(inputData.draftId);
5 const brief = await db.getBrief(inputData.briefId);
6
7 await notifications.send({
8 channel: "slack",
9 message: `Draft ready for review: "${draft.title}". Review at [editor URL]`,
10 });
11
12 const reviewResult = await suspend({
13 type: "draft-review",
14 draftId: inputData.draftId,
15 briefId: inputData.briefId,
16 message: "Awaiting human review of draft",
17 });
18
19 if (reviewResult.action === "rejected") {
20 // Send back to Content Layer with rejection notes
21 return { rejected: true, rejectionNotes: reviewResult.notes };
22 }
23
24 // Human approved — return the human-edited content
25 return {
26 rejected: false,
27 editedContent: reviewResult.editedContent,
28 imagesPlaced: reviewResult.imagesPlaced,
29 };
30 },
31});6.3 Final Cleanup Agent
After human edits, the Final Cleanup agent makes a technical-only pass. It does not change tone, wording, or content. It fixes formatting issues introduced during human editing.
Tools: verifyInternalLinks only.
maxSteps: 6. Typically 1-2 tool calls + 1 cleanup pass.
6.4 SEO Validation & Auto-Fix Loop
The SEO validation engine (Addendum #2, Section 6) runs as deterministic code, not an LLM. If any BLOCKING check fails, the SEO Auto-Fix agent attempts repair.
1const seoFixLoopStep = createStep({
2 id: "seo-fix-loop",
3 execute: async ({ inputData }) => {
4 let content = inputData.content;
5 let validation = runSeoValidation(content);
6 let fixCycles = 0;
7
8 while (!validation.passed && fixCycles < 3) {
9 const failures = validation.checks
10 .filter(c => c.tier === "BLOCKING" && !c.passed && c.autoFixable);
11
12 if (failures.length === 0) break; // non-fixable failures remain
13
14 const fixAgent = await createConfiguredAgent("seo-autofix", ...);
15 const fixResult = await fixAgent.generate([
16 { role: "user", content: buildAutoFixPrompt(content, failures) }
17 ]);
18
19 content = fixResult.text;
20 validation = runSeoValidation(content);
21 fixCycles++;
22 }
23
24 if (!validation.passed) {
25 // Escalate to human
26 await notifications.send({
27 channel: "slack",
28 message: `SEO validation failed after ${fixCycles} fix cycles. Manual correction needed.`,
29 details: validation.checks.filter(c => !c.passed),
30 });
31 }
32
33 return { content, validation, fixCycles };
34 },
35});6.5 Publishing Step
If SEO validation passes (10/10 BLOCKING score), the Publishing agent executes the publication pipeline using its 7 tools in sequence: format → upload → set metadata → ping indexing → trigger post-publish → schedule monitoring → log to audit trail.
The Publishing agent has maxSteps: 15 because it must call each tool in sequence, and some tools may require retry on transient failures. The agent is instructed to execute tools in a specific order and to halt if any step fails.
7. Human Gate Contracts
Each human touchpoint is a workflow suspension. The workflow suspends and provides a payload describing what the human needs to do. The Next.js app reads this payload, renders the appropriate UI, and calls the Mastra server's resume endpoint when the human completes their action.
7.1 Gate 1: Calendar Review
| Field | Value |
|---|---|
| Suspend Event Type | calendar-review |
| Trigger | Content Strategy agent completes plan generation |
| UI Surface | Calendar view (parent spec Section 7.1) |
Suspend payload:
1{
2 type: "calendar-review",
3 workflowRunId: string,
4 planItemIds: string[],
5 generatedAt: Date,
6 message: string,
7}Resume payload (sent by Next.js app when human completes review):
1{
2 approvedItemIds: string[], // items human approved
3 rejectedItemIds: string[], // items human rejected
4 editedItems: Array<{ // items human modified before approving
5 id: string,
6 changes: Partial<ContentPlanItem>,
7 }>,
8 humanAddedItems: string[], // IDs of items human created during review
9}Resume API call:
1POST /api/workflows/strategy-layer/runs/{runId}/resume
2Content-Type: application/json
3Body: { resumePayload: { ... } }7.2 Gate 2: Brief Approval
| Field | Value |
|---|---|
| Suspend Event Type | brief-approval |
| Trigger | Content Brief agent completes brief generation |
| UI Surface | Brief review panel (inline in calendar item detail view) |
Suspend payload:
1{
2 type: "brief-approval",
3 workflowRunId: string,
4 briefId: string,
5 planItemId: string,
6 message: string,
7}Resume payload:
1{
2 action: "approved" | "revision_requested",
3 revisionNotes?: string, // if revision_requested
4 editedOutline?: ContentBrief, // if human modified the brief before approving
5}7.3 Gate 3: Draft Review
| Field | Value |
|---|---|
| Suspend Event Type | draft-review |
| Trigger | Content Layer workflow completes (draft + edits saved) |
| UI Surface | Content editor (parent spec Section 7.2) |
Suspend payload:
1{
2 type: "draft-review",
3 workflowRunId: string,
4 draftId: string,
5 briefId: string,
6 planItemId: string,
7 editorNotes: EditorOutput, // Editor's assessment for human context
8 message: string,
9}Resume payload:
1{
2 action: "approved" | "rejected",
3 editedContent: string, // the human-edited markdown (with images placed)
4 imagesPlaced: Array<{
5 marker: string, // original [IMAGE: ...] marker text
6 imageUrl: string,
7 altText: string,
8 width: number,
9 height: number,
10 }>,
11 rejectionNotes?: string, // if rejected — sent back to Content Layer
12}7.4 Resume Mechanism
The Next.js application calls the Mastra server's workflow resume endpoint. Mastra's built-in suspend/resume handles the state management — the workflow run is persisted to storage while suspended and rehydrated on resume.
The Next.js app needs a mapping of active workflow runs to their current suspend state. This is queried via:
1GET /api/workflows/{workflowId}/runs?status=suspendedReturns all suspended runs with their suspend payloads, allowing the UI to render the correct review interface per item.
8. Post-Publish Pipeline
8.1 Design Decision: Trigger.dev Job Chain
The post-publish pipeline has no human gates — it is fully automated. This makes it a better fit for a Trigger.dev job chain than a Mastra workflow. Trigger.dev provides retry-on-failure, job status monitoring, and scheduled execution, which are the primary requirements here.
8.2 Pipeline Specification
The pipeline is triggered by Tool P5 (triggerPostPublishPipeline) when the Publishing agent completes.
1Event: page.published
2Payload: { pageId, url, isRefresh }
3
4Job 1: extract-new-page
5 → POST /extract to LangExtract sidecar with document_type=our_page
6 → Write results to our_page_extractions
7 → Emit: extraction.complete { pageId }
8
9Job 2: generate-page-summary (triggered by extraction.complete)
10 → Generate Level 2 page summary from extraction entities
11 → Write to page_summaries
12 → Emit: summary.page.complete { pageId, clusterId }
13
14Job 3: regenerate-cluster-summary (triggered by summary.page.complete)
15 → Regenerate Level 1 cluster summary for the affected cluster
16 → Write to cluster_summaries
17 → Emit: summary.cluster.complete { clusterId }
18
19Job 4: regenerate-domain-summary (triggered by summary.cluster.complete)
20 → Regenerate Level 0 domain summary
21 → Write to domain_summaries
22
23Job 5: build-graph-edges (triggered by extraction.complete)
24 → Runs in parallel with Jobs 2-4
25 → Build covers_topic, targets_keyword, links_to edges from extraction data
26 → Run should_link_to analysis: find existing pages that should link to new page
27 → Write all edges to content_relationships
28
29Job 6: execute-bidirectional-linking (triggered by build-graph-edges.complete)
30 → For each should_link_to edge: queue the source page for a light content update
31 that adds an internal link to the new page
32 → This is a lightweight, automated edit — not a full refresh cycle8.3 Failure Handling
Each job has a retry policy: 3 retries with exponential backoff (10s, 30s, 90s). If a job fails after all retries, it is marked as failed and an alert is sent to Slack. Downstream jobs that depend on the failed job do not run. The pipeline can be manually re-triggered from the dashboard for the failed job onward.
Critical: The publishing action is NOT rolled back on pipeline failure. The content is live. The pipeline failure means the system's internal data (summaries, graph, extractions) is temporarily out of sync with the published content. This is acceptable because the next scheduled data job will catch up, and the content itself is not affected.
9. Error Handling & Step Limits
9.1 maxSteps Policy
maxSteps is a safety boundary, not a performance target. When an agent hits its step limit, Mastra forces the agent to stop and return whatever it has. This is a partial result.
What happens when an agent hits maxSteps:
- The workflow step captures the partial output.
- The step status is set to
failed_max_steps. - The partial output is logged to the audit trail with the full tool call history (which tools were called, in what order, how many tokens consumed).
- A Slack notification is sent: "Agent [name] hit step limit ([N] steps). Partial output saved. Review required."
- The workflow marks the current run as
failedand does not proceed to the next step.
This is not a silent failure. The human sees it in the dashboard and can either re-run the workflow (the agent may succeed on retry with different reasoning) or manually complete the task.
9.2 Why maxSteps Matters
Consider the Content Strategy agent with maxSteps: 20. Its expected tool call pattern uses ~9 tool calls. The 20-step budget provides margin for:
- Retrying a tool call that returned empty results with different parameters
- Making additional
readPageSummariescalls to drill into specific clusters - Running extra
webSearchcalls to validate opportunities - The final structured output generation call
If the agent wastes steps (e.g., calling readDomainSummary multiple times with the same parameters, or searching for irrelevant topics), it may exhaust its budget before completing. This is a prompt engineering problem, not a system problem. The audit trail (Section 10) makes it visible which tools were called and in what order, enabling prompt iteration.
9.3 Recommended maxSteps Review Cadence
After the first 50 workflow runs, review the audit trail to determine actual step usage per agent. If the median step count for an agent is less than 50% of its maxSteps budget, the budget can likely be reduced. If any agent hits maxSteps more than 5% of the time, either the budget needs to increase or the agent's instructions need refinement.
9.4 Other Failure Modes
| Failure | Handling |
|---|---|
| Tool returns error (API down, rate limit, etc.) | Agent sees the error message and can decide to retry or use alternative approach. If the tool is critical and the agent cannot proceed, it should indicate this in its output. |
| LLM returns malformed structured output | Zod validation catches this. The workflow step retries the agent call once with a note appended: "Your previous response did not match the required output schema. Please ensure your response exactly matches the schema." If retry fails, step fails. |
| Workflow step timeout | Steps have a 5-minute timeout (configurable per step). On timeout, same handling as maxSteps — partial output saved, step marked failed, notification sent. |
| LangExtract sidecar unavailable | Circuit breaker pattern (Addendum #1, Section 6.2). Post-publish pipeline jobs queue for retry. No data loss. |
| Supabase connection failure | Standard retry with backoff. If persistent, all workflows pause and alert fires. |
10. Audit Trail
10.1 Design Purpose
The audit trail serves three functions: debugging (why did the agent make that decision?), cost tracking (how many tokens did this workflow run consume?), and quality improvement (which agents waste steps or make poor tool choices?).
10.2 Schema
1CREATE TABLE agent_audit_log (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3
4 -- Context
5 workflow_id VARCHAR(50) NOT NULL,
6 workflow_run_id VARCHAR(100) NOT NULL,
7 step_id VARCHAR(50) NOT NULL,
8 agent_id VARCHAR(50) NOT NULL,
9 model_id VARCHAR(100) NOT NULL,
10
11 -- Timing
12 started_at TIMESTAMP NOT NULL,
13 completed_at TIMESTAMP,
14 duration_ms INTEGER,
15
16 -- Result
17 status VARCHAR(20) NOT NULL, -- 'success', 'failed', 'failed_max_steps', 'timeout'
18 steps_used INTEGER NOT NULL,
19 max_steps_configured INTEGER NOT NULL,
20
21 -- Token Usage
22 input_tokens INTEGER NOT NULL DEFAULT 0,
23 output_tokens INTEGER NOT NULL DEFAULT 0,
24 total_tokens INTEGER NOT NULL DEFAULT 0,
25 estimated_cost_usd FLOAT DEFAULT 0.0,
26
27 -- Tool Calls (ordered sequence of tool invocations)
28 tool_calls JSONB NOT NULL DEFAULT '[]',
29 -- Structure: [{ tool_name, input_summary, output_summary,
30 -- duration_ms, tokens_used, step_number }]
31
32 -- Output
33 output_summary TEXT, -- truncated agent output for quick review
34 full_output_ref VARCHAR(200), -- S3/storage reference for full output if large
35
36 -- Error
37 error_message TEXT,
38 error_type VARCHAR(50),
39
40 created_at TIMESTAMP DEFAULT now()
41);
42
43CREATE INDEX idx_audit_workflow_run ON agent_audit_log(workflow_run_id);
44CREATE INDEX idx_audit_agent ON agent_audit_log(agent_id);
45CREATE INDEX idx_audit_status ON agent_audit_log(status);
46CREATE INDEX idx_audit_date ON agent_audit_log(created_at);
47CREATE INDEX idx_audit_cost ON agent_audit_log(estimated_cost_usd);10.3 What Gets Logged
Every agent invocation produces exactly one agent_audit_log row. The tool_calls JSONB array captures the full sequence of tool invocations within that agent run:
1[
2 {
3 "step_number": 1,
4 "tool_name": "readDomainSummary",
5 "input_summary": "{ scope: 'combined' }",
6 "output_summary": "Returned domain summary, 487 tokens",
7 "duration_ms": 120,
8 "tokens_used": 487
9 },
10 {
11 "step_number": 2,
12 "tool_name": "queryCompetitorChanges",
13 "input_summary": "{ since: '2026-02-27', changeType: 'new' }",
14 "output_summary": "Returned 12 changes across 4 competitors",
15 "duration_ms": 85,
16 "tokens_used": 1240
17 }
18]input_summary and output_summary are truncated representations (max 500 characters each) to keep the JSONB field manageable. Full tool inputs and outputs are available in the Mastra observability logs for detailed debugging.
10.4 Cost Tracking
estimated_cost_usd is calculated at log time using the model ID and token counts. The calculation uses a cost lookup table:
1CREATE TABLE model_pricing (
2 model_id VARCHAR(100) PRIMARY KEY,
3 input_cost_per_1m FLOAT NOT NULL, -- USD per 1M input tokens
4 output_cost_per_1m FLOAT NOT NULL, -- USD per 1M output tokens
5 effective_date DATE NOT NULL,
6 notes TEXT
7);This table is manually maintained when pricing changes. The audit trail cost is an estimate — actual billing may differ due to caching, batching, or pricing tier changes.
10.5 Dashboard Queries
The audit trail supports the following dashboard views:
Cost by period: SELECT SUM(estimated_cost_usd), date_trunc('day', created_at) FROM agent_audit_log GROUP BY 2
Agent efficiency: SELECT agent_id, AVG(steps_used), AVG(max_steps_configured), COUNT(*) FILTER (WHERE status = 'failed_max_steps') FROM agent_audit_log GROUP BY agent_id
Workflow run detail: SELECT * FROM agent_audit_log WHERE workflow_run_id = ? ORDER BY started_at
Slowest agents: SELECT agent_id, AVG(duration_ms), MAX(duration_ms) FROM agent_audit_log GROUP BY agent_id ORDER BY 2 DESC
11. Brand Voice & Prompt Architecture
11.1 Prompt Assembly
Agent instructions are assembled at invocation time from multiple sources. The assembly is performed in the workflow step before the agent is created, not inside the agent definition itself.
1┌──────────────────────────────────────────────┐
2│ Final Agent Prompt │
3│ │
4│ ┌─────────────────────────────────────────┐ │
5│ │ 1. Base Instructions (static) │ │
6│ │ Role definition, behavioral rules, │ │
7│ │ output format requirements │ │
8│ └─────────────────────────────────────────┘ │
9│ ┌─────────────────────────────────────────┐ │
10│ │ 2. Brand Voice (from DB, ~500 tokens) │ │
11│ │ Tone markers, vocabulary, patterns, │ │
12│ │ words to avoid │ │
13│ └─────────────────────────────────────────┘ │
14│ ┌─────────────────────────────────────────┐ │
15│ │ 3. Strategy Context (from DB, ~500 tok) │ │
16│ │ Pillars, priorities, audience │ │
17│ └─────────────────────────────────────────┘ │
18│ ┌─────────────────────────────────────────┐ │
19│ │ 4. Task-Specific Context (variable) │ │
20│ │ Brief, intel reports, etc. │ │
21│ └─────────────────────────────────────────┘ │
22└──────────────────────────────────────────────┘11.2 Brand Voice Settings
Brand voice configuration is managed in the Strategy Settings UI (parent spec Section 7.3). The settings capture two types of data:
Human-entered settings (stored in content_strategy):
- Brand voice guidelines (free text)
- Target audience description
- Content pillars
- Words/phrases to always avoid
- Preferred terminology mappings (e.g., "always say 'team members' not 'employees'")
LangExtract-derived voice data (stored in brand_voice_extractions):
- Tone markers extracted from sample content
- Vocabulary preferences extracted from sample content
- Sentence patterns extracted from sample content
Both are loaded and combined when assembling prompts for the Writer and Editor agents. The human-entered settings take precedence — if a human says "never use the word 'leverage'" but the extracted samples contain it, the human rule wins.
11.3 Sample Content Management
The Strategy Settings UI includes a "Voice Samples" section where the content lead uploads 5-10 representative pieces that define the target voice. When samples are uploaded or changed, the system triggers a LangExtract brand_voice extraction (Addendum #1, Section 3.5) and stores the results.
The UI displays the extracted tone markers, vocabulary, and patterns so the content lead can verify the extraction captured the intended voice. If the extraction misses something, the human adds it manually in the settings.
11.4 Prompt Size Budget
All agents operate well within Sonnet's 200K context window. The largest prompt is the Writer agent for a comprehensive guide:
| Component | Estimated Tokens |
|---|---|
| Base instructions | ~800 |
| Brand voice data | ~500 |
| Strategy context | ~500 |
| Full content brief (guide, 5,000 words target) | ~3,000 |
| Tool results accumulated during execution | ~4,000 |
| Total | ~8,800 |
This is under 5% of the context window. Prompt size is not a constraint for any current agent.
12. Open Questions for Review
| # | Question | Impact | Recommended Default |
|---|---|---|---|
| 1 | Should the audit trail store full tool inputs/outputs or only summaries? Full storage enables replay but increases storage cost. | Debugging depth vs. storage | Summaries in Postgres, full data in Mastra's built-in observability logs (time-limited retention). |
| 2 | The post-publish pipeline uses Trigger.dev job chains. Should we use Mastra workflows instead for consistency, even though there are no human gates? | Architectural consistency vs. simplicity | Trigger.dev. No human gates = no need for suspend/resume = simpler job chain. |
| 3 | Should agents be allowed to call other agents directly (supervisor pattern) for any use case, or should all inter-agent communication go through workflow state? | Flexibility vs. traceability | Workflow state only for MVP. Supervisor pattern is a future consideration if we need dynamic task decomposition. |
| 4 | The revision loop is bounded at 2 cycles. Should this be configurable per content type (e.g., guides get 3 cycles, glossary gets 1)? | Quality vs. cost | Fixed at 2 for MVP. Review after 50 pieces to see if any content type consistently needs more. |
| 5 | Agent temperature is set per agent in the config table. Should temperature also be adjustable per content type (e.g., Writer at 0.8 for blog posts but 0.6 for case studies)? | Granularity vs. complexity | Per-agent only for MVP. Per content-type temperature adds schema and UI complexity with uncertain benefit. |
| 6 | The SEO Auto-Fix agent currently has access to setMetadata only. Should it also be able to make minor content body edits (e.g., inserting a keyword in the first paragraph)? | Auto-fix scope | Yes — give it setMetadata plus a patchContentBody tool that can make targeted string replacements (not full rewrites). Define the patch tool in implementation. |
| 7 | When a human rejects a draft at Gate 3, should the content go back to the Content Layer (Writer + Editor) or to a simpler "revision" workflow that only involves the Writer? | Pipeline complexity | Back to the full Content Layer. The Editor acts as quality gate on revisions too. The human's rejection notes are passed as additional context. |
End of Addendum #3
Next addendum: Hierarchical Summary Generation — defining how LangExtract extraction outputs are aggregated into navigable Level 0–3 summaries for agent consumption.