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:
- Create a “Desktop App” OAuth credential in Google Cloud Console
- Download the credentials JSON
- Run the auth script once — it opens a browser for consent
- The script saves the refresh token locally
- 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.