obi-jam pattern intermediate

Self-Hosted Open Source as Agent Infrastructure

The fastest way to give your agent real capabilities isn't building them. It's installing something that already works and pointing your agent at the API.

The Pattern

When you need your agent to manage tasks, check calendars, or send reminders, the instinct is to build it. Task CRUD, a database schema, reminder scheduling, maybe a webhook system. That’s weeks of work for table-stakes functionality.

The alternative: install a self-hosted open-source tool that already does it, and point your agent at its API.

@Obi runs on a Mac Mini and talks to two self-hosted services:

  • Vikunja — task management with projects, priorities, due dates, labels, reminders, and a full REST API
  • Google Calendar — event scheduling via the googleapis client library with OAuth2

The agent doesn’t implement task management. It’s a conversational interface on top of infrastructure that already works.

Why Self-Hosted

The economics are stark. Building equivalent functionality from scratch means:

  • Task CRUD with priorities, due dates, labels
  • Recurring task support
  • Reminder scheduling and delivery
  • Per-user auth (if you ever add users)
  • Webhook integrations
  • A UI for manual edits

Using SaaS APIs means $30-50/month for Todoist + calendar + reminder services, plus vendor lock-in and rate limits.

Self-hosting Vikunja on the same Mac Mini that runs the agent: $0/month, localhost latency, full data ownership, and an API that’s richer than most SaaS offerings.

The Module Pattern

Each capability is a module that exposes one standard method:

// modules/tasks.js
export function getMorningBriefSummary() {
  const tasks = await fetchVikunjaTasksDueToday();
  const suggested = prioritizeTasks(await fetchAllOpenTasks());
  return formatTaskBrief(tasks, suggested);
}

// modules/calendar.js
export function getMorningBriefSummary() {
  const events = await getTodayEvents();
  return formatCalendarBrief(events);
}

The orchestrator doesn’t care what’s inside each module. It collects summaries and posts them:

// scheduler.js
const BRIEF_HOUR = 6;
const BRIEF_MINUTE = 30;
let briefPostedToday = false;

setInterval(async () => {
  const now = new Date();
  if (now.getHours() === BRIEF_HOUR && now.getMinutes() === BRIEF_MINUTE && !briefPostedToday) {
    const sections = await Promise.all(
      modules.map(m => m.getMorningBriefSummary())
    );
    const brief = sections.filter(Boolean).join('\n\n');
    await sendToDiscord(brief);
    briefPostedToday = true;
  }
  // Reset at midnight
  if (now.getHours() === 0 && now.getMinutes() === 0) {
    briefPostedToday = false;
  }
}, 60_000);

Adding a new data source to the morning brief means implementing one method. The orchestrator handles assembly.

Vikunja Task Prioritization

The morning brief doesn’t dump every open task. It selects a prioritized subset — enough to be actionable, not so many that you ignore the list:

async function getSuggestedTasks() {
  const tasks = await fetchAllOpenTasks();

  const urgent = tasks
    .filter(t => t.priority >= 4)
    .sort((a, b) => (a.dueDate || Infinity) - (b.dueDate || Infinity))
    .slice(0, 3);

  const soon = tasks
    .filter(t => t.priority >= 2 && t.priority < 4 && !urgent.includes(t))
    .sort((a, b) => (a.dueDate || Infinity) - (b.dueDate || Infinity))
    .slice(0, 2);

  const backlog = tasks
    .filter(t => t.priority < 2 && !urgent.includes(t) && !soon.includes(t))
    .slice(0, 1);

  return [...urgent, ...soon, ...backlog]; // max 6
}

Three urgent, two soon, one backlog. The user gets a focused list every morning without having to triage manually.

Google Calendar: OAuth2 Desktop Flow for Headless Agents

Google’s OAuth2 has a specific flow for desktop/headless applications. The key difference from web apps: you use a “Desktop App” credential type, and the first authorization happens through a local redirect.

import { google } from 'googleapis';
import { readFileSync, writeFileSync, existsSync } from 'fs';

const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
const TOKEN_PATH = './config/google-token.json';

function getAuthClient() {
  const { client_id, client_secret } = JSON.parse(
    readFileSync('./config/google-credentials.json', 'utf-8')
  ).installed;

  const auth = new google.auth.OAuth2(client_id, client_secret, 'http://localhost:3000/callback');

  if (existsSync(TOKEN_PATH)) {
    auth.setCredentials(JSON.parse(readFileSync(TOKEN_PATH, 'utf-8')));
    auth.on('tokens', (tokens) => {
      // Auto-save refreshed tokens
      const current = JSON.parse(readFileSync(TOKEN_PATH, 'utf-8'));
      writeFileSync(TOKEN_PATH, JSON.stringify({ ...current, ...tokens }));
    });
    return auth;
  }

  // First run: generate auth URL, user visits it, paste the code
  const url = auth.generateAuthUrl({ access_type: 'offline', scope: SCOPES });
  console.log('Visit this URL to authorize:', url);
  // ... handle the callback, save tokens to TOKEN_PATH
}

The setup:

  1. Create a “Desktop App” OAuth credential in Google Cloud Console
  2. Download the credentials JSON
  3. Run the auth script once — it opens a browser for consent
  4. The script saves the refresh token locally
  5. From then on, the agent uses the refresh token to auto-refresh access tokens — fully headless
async function getTodayEvents() {
  const auth = getAuthClient();
  const calendar = google.calendar({ version: 'v3', auth });

  const startOfDay = new Date();
  startOfDay.setHours(0, 0, 0, 0);
  const endOfDay = new Date();
  endOfDay.setHours(23, 59, 59, 999);

  const res = await calendar.events.list({
    calendarId: 'primary',
    timeMin: startOfDay.toISOString(),
    timeMax: endOfDay.toISOString(),
    singleEvents: true,
    orderBy: 'startTime',
  });

  return res.data.items || [];
}

Key Takeaway

Your agent doesn’t need to be a task manager, a calendar app, or a reminder system. It needs to talk to things that already are. Self-hosted open-source tools give you production-grade backends at $0/month with full API access and data ownership. The agent’s job is the conversational layer — understanding intent, routing to the right service, and presenting results. Let proven infrastructure handle the rest.

FAQ

Why self-host instead of using a SaaS API for agent task management?

Three reasons: cost, control, and latency. A self-hosted Vikunja instance on your Mac Mini costs $0/month, responds in single-digit milliseconds over localhost, and you own the data. The equivalent SaaS stack (Todoist API + Google Tasks + a reminder service) would run $30-50/month and add network latency to every agent action.

How do I connect Vikunja to my AI agent?

Vikunja exposes a full REST API. Generate an API token in Vikunja's settings, then make HTTP calls from your agent's task module. Use Bearer token auth in the Authorization header. The key endpoints are /api/v1/projects/{id}/tasks for listing and /api/v1/tasks for creating. Your agent module wraps these calls and exposes a simple interface like getMorningBriefSummary() to the orchestrator.

How does the Google OAuth2 desktop flow work for headless agents?

Create a Desktop App credential in Google Cloud Console (not a web app). On first run, the script opens a browser for consent and saves the refresh token to a local file. After that, the googleapis client auto-refreshes the access token using the stored refresh token — no browser needed. Your agent uses the refresh token forever, fully headless.

What is the module pattern for agent morning briefs?

Each capability module (calendar, tasks, reminders) independently exposes a getMorningBriefSummary() method that returns a formatted string. The orchestrator's scheduler checks the time every minute, and at the configured brief time, calls getMorningBriefSummary() on every registered module, assembles the results, and posts to Discord. Modules don't know about each other — adding a new data source means implementing one method.