Skip to main content
Background agents run autonomously on schedules without user interaction, enabling automated workflows like daily digests, data sync, and system monitoring.

Overview

Background agents are defined as Markdown files in the agents/ directory and scheduled via ~/.rowboat/config/agent-schedule.json. The background runner polls every minute to check for agents that need to run.
Runner Implementation: apps/x/packages/core/src/agent-schedule/runner.ts:1The runner executes as a background service, managing schedules, timeouts, and state persistence.

Schedule Configuration

Configuration File

Schedules are defined in ~/.rowboat/config/agent-schedule.json:
{
  "agents": {
    "agent_name": {
      "schedule": { /* schedule definition */ },
      "enabled": true,
      "description": "What this agent does",
      "startingMessage": "Initial message to agent"
    }
  }
}

Schedule Types

Runs at exact times defined by cron expression
{
  "schedule": {
    "type": "cron",
    "expression": "0 8 * * *"
  },
  "enabled": true
}
Common expressions:
  • */5 * * * * - Every 5 minutes
  • 0 8 * * * - Every day at 8am
  • 0 9 * * 1 - Every Monday at 9am
  • 0 0 1 * * - First day of every month at midnight
  • 0 */2 * * * - Every 2 hours
  • 30 14 * * 1-5 - Weekdays at 2:30pm
Next run calculation:
const interval = CronExpressionParser.parse(schedule.expression, {
  currentDate: now
});
return toLocalISOString(interval.next().toDate());
Location: apps/x/packages/core/src/agent-schedule/runner.ts:62
Runs once during a time window (randomized)
{
  "schedule": {
    "type": "window",
    "cron": "0 0 * * *",
    "startTime": "08:00",
    "endTime": "10:00"
  },
  "enabled": true
}
The agent runs once at a random time within the window. Use this for flexible timing (“sometime in the morning” rather than “exactly at 8am”).Next run calculation:
// Parse base cron for date
const interval = CronExpressionParser.parse(schedule.cron);
const nextDate = interval.next().toDate();

// Pick random time in window
const [startHour, startMin] = schedule.startTime.split(":").map(Number);
const [endHour, endMin] = schedule.endTime.split(":").map(Number);
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const randomMinutes = startMinutes + 
  Math.floor(Math.random() * (endMinutes - startMinutes));

nextDate.setHours(
  Math.floor(randomMinutes / 60),
  randomMinutes % 60,
  0, 0
);
Location: apps/x/packages/core/src/agent-schedule/runner.ts:72
Runs exactly once at a specific time
{
  "schedule": {
    "type": "once",
    "runAt": "2024-02-05T10:30:00"
  },
  "enabled": true
}
Use for one-time tasks like migrations or setup scripts. The runAt time is in local time (no Z suffix).Execution check:
if (entry.schedule.type === "once") {
  const runAt = new Date(entry.schedule.runAt);
  return now >= runAt && state?.status !== "triggered";
}
Location: apps/x/packages/core/src/agent-schedule/runner.ts:128

Starting Message

Optional message sent to the agent on startup:
{
  "startingMessage": "Summarize my emails from the last 24 hours"
}
Defaults to "go" if not specified. Location: apps/x/packages/core/src/agent-schedule/runner.ts:13

Description

Optional description displayed in the UI:
{
  "description": "Daily email and calendar summary"
}

Schedule State

IMPORTANT: Do NOT manually edit agent-schedule-state.json - it is managed automatically by the runner.
The runner tracks execution state in ~/.rowboat/config/agent-schedule-state.json:
{
  "agents": {
    "agent_name": {
      "status": "finished",
      "lastRunAt": "2024-02-05T08:00:00",
      "nextRunAt": "2024-02-06T08:00:00",
      "startedAt": null,
      "lastError": null,
      "runCount": 42
    }
  }
}

State Fields

FieldDescription
statusscheduled, running, finished, failed, triggered
lastRunAtISO timestamp of last execution
nextRunAtISO timestamp of next scheduled execution
startedAtISO timestamp when current run started (null if not running)
lastErrorError message from last failed run (null if successful)
runCountTotal number of executions

Runner Implementation

Main Loop

The runner executes every minute:
export async function init(): Promise<void> {
  console.log("[AgentRunner] Starting background agent runner service");
  
  while (true) {
    try {
      await pollAndRun();
    } catch (error) {
      console.error("[AgentRunner] Error in main loop:", error);
    }
    
    await interruptibleSleep(POLL_INTERVAL_MS); // 60 seconds
  }
}
Location: apps/x/packages/core/src/agent-schedule/runner.ts:323

Interruptible Sleep

Allows immediate wake on trigger:
let wakeResolve: (() => void) | null = null;

export function triggerRun(): void {
  if (wakeResolve) {
    console.log("[AgentRunner] Triggered - waking up immediately");
    wakeResolve();
    wakeResolve = null;
  }
}

function interruptibleSleep(ms: number): Promise<void> {
  return new Promise((resolve) => {
    const timeout = setTimeout(() => {
      wakeResolve = null;
      resolve();
    }, ms);
    wakeResolve = () => {
      clearTimeout(timeout);
      resolve();
    };
  });
}
Location: apps/x/packages/core/src/agent-schedule/runner.ts:28

Poll and Run

// Check for timed-out agents first
async function checkForTimeouts(
  state: AgentScheduleState,
  config: AgentScheduleConfig,
  stateRepo: IAgentScheduleStateRepo
): Promise<void> {
  const now = new Date();
  
  for (const [agentName, agentState] of Object.entries(state.agents)) {
    if (agentState.status === "running" && agentState.startedAt) {
      const startedAt = new Date(agentState.startedAt);
      const elapsed = now.getTime() - startedAt.getTime();
      
      if (elapsed > TIMEOUT_MS) { // 30 minutes
        const entry = config.agents[agentName];
        const nextRunAt = entry ? calculateNextRunAt(entry.schedule) : null;
        
        await stateRepo.updateAgentState(agentName, {
          status: "failed",
          startedAt: null,
          lastRunAt: toLocalISOString(now),
          nextRunAt: nextRunAt,
          lastError: `Timed out after ${Math.round(elapsed / 1000 / 60)} minutes`,
          runCount: (agentState.runCount ?? 0) + 1
        });
      }
    }
  }
}
Location: apps/x/packages/core/src/agent-schedule/runner.ts:258

Run Agent

async function runAgent(
  agentName: string,
  entry: AgentScheduleEntry,
  stateRepo: IAgentScheduleStateRepo,
  runsRepo: IRunsRepo,
  agentRuntime: IAgentRuntime,
  idGenerator: IMonotonicallyIncreasingIdGenerator
): Promise<void> {
  console.log(`[AgentRunner] Starting agent: ${agentName}`);
  
  const startedAt = toLocalISOString(new Date());
  
  // Update state to running
  await stateRepo.updateAgentState(agentName, {
    status: "running",
    startedAt: startedAt
  });
  
  try {
    // Create run
    const run = await runsRepo.create({ agentId: agentName });
    
    // Add starting message
    const startingMessage = entry.startingMessage ?? "go";
    await runsRepo.appendEvents(run.id, [{
      runId: run.id,
      type: "message",
      messageId: await idGenerator.next(),
      message: {
        role: "user",
        content: startingMessage
      },
      subflow: []
    }]);
    
    // Trigger execution
    await agentRuntime.trigger(run.id);
    
    // Calculate next run
    const nextRunAt = calculateNextRunAt(entry.schedule);
    
    // Update state to finished
    await stateRepo.updateAgentState(agentName, {
      status: entry.schedule.type === "once" ? "triggered" : "finished",
      startedAt: null,
      lastRunAt: toLocalISOString(new Date()),
      nextRunAt: nextRunAt,
      lastError: null,
      runCount: (currentState?.runCount ?? 0) + 1
    });
  } catch (error) {
    console.error(`[AgentRunner] Error running agent ${agentName}:`, error);
    
    // Update state to failed
    const nextRunAt = calculateNextRunAt(entry.schedule);
    await stateRepo.updateAgentState(agentName, {
      status: "failed",
      startedAt: null,
      lastRunAt: toLocalISOString(new Date()),
      nextRunAt: nextRunAt,
      lastError: error instanceof Error ? error.message : String(error),
      runCount: (currentState?.runCount ?? 0) + 1
    });
  }
}
Location: apps/x/packages/core/src/agent-schedule/runner.ts:146

Complete Example

Multi-Agent Workflow

Email reader agent (agents/email_reader.md):
---
model: gpt-5.1
tools:
  read_file:
    type: builtin
    name: workspace-readFile
  list_dir:
    type: builtin
    name: workspace-readdir
---
# Email Reader Agent

Read emails from the gmail_sync folder and extract key information.
Look for unread or recent emails and summarize the sender, subject, and key points.
Don't ask for human input.
Daily summary agent (agents/daily_summary.md):
---
model: gpt-5.1
tools:
  email_reader:
    type: agent
    name: email_reader
  write_file:
    type: builtin
    name: workspace-writeFile
---
# Daily Summary Agent

1. Use the email_reader tool to get email summaries
2. Create a consolidated daily digest
3. Save the digest to ~/Desktop/daily_digest.md

Don't ask for human input.
Schedule configuration (~/.rowboat/config/agent-schedule.json):
{
  "agents": {
    "daily_summary": {
      "schedule": {
        "type": "cron",
        "expression": "0 7 * * *"
      },
      "enabled": true,
      "description": "Daily email summary",
      "startingMessage": "Create my daily email digest"
    }
  }
}

Best Practices

Single responsibility - Each agent should do one thing wellAutonomous operation - Add “Don’t ask for human input” to instructionsClear delegation - Explicitly state when to call other agentsData passing - Make it clear what data to extract and pass between agentsError handling - Background agents should handle errors gracefully
Avoid executeCommand - Don’t attach executeCommand to background agents running unattendedUse specific tools - Use workspace-readFile, workspace-writeFile, etc. insteadValidate inputs - Check data before processingFile paths - Ask users for output paths, then hardcode in instructions
Choose appropriate schedule type:
  • Cron - Predictable, exact timing
  • Window - Flexible timing (“sometime in the morning”)
  • Once - One-time tasks, migrations
Consider frequency:
  • Too frequent = wasted resources
  • Too infrequent = stale data
Timezone awareness:
  • All times are in local machine timezone
  • Document expected timezone in descriptions

Troubleshooting

// Check if enabled
{
  "agents": {
    "my_agent": {
      "enabled": false  // ← Problem
    }
  }
}

// Check if agent file exists
// agents/my_agent.md must exist

// Check state for errors
{
  "agents": {
    "my_agent": {
      "status": "failed",
      "lastError": "Agent file not found"  // ← Check this
    }
  }
}

Next Steps

Runtime Architecture

Understand how agents execute

Skills

Explore available skills

Build docs developers (and LLMs) love