AI & Automation

Building an Autonomous AI Assistant on a VPS

How I built ClaudeUltra — a self-running AI operations hub with Telegram bot, automated prospection, cron jobs, and multi-agent orchestration. A deep dive into the architecture and lessons learned.

10 mars 20268 min de lecture
AI
Node.js
Telegram
Automation
VPS
Claude AI

Why I Built ClaudeUltra

Let me be honest: I was spending way too much time on repetitive stuff. Checking job boards, filtering through irrelevant missions, following up on leads, doing research before client calls. All of it useful, none of it exciting — and all of it stealing time from actual client work.

I needed a system that would handle the grind autonomously. Not a bunch of disconnected scripts duct-taped together, but a proper hub — something with a brain, a persistent state, and a clean interface I could use from anywhere. Something that would just... keep running.

That's how ClaudeUltra was born. The name is a bit tongue-in-cheek — it's a reference to Anthropic's Claude, which powers most of the AI logic. Ultra because, eh, pourquoi pas.

The core idea: a VPS running 24/7 that monitors job boards, orchestrates AI sub-agents, sends me Telegram messages when something needs attention, and handles a growing list of automated tasks — all while I sleep or write code for clients.

The Architecture

I went with a VPS on Hostinger — Ubuntu 24.04, 4 vCPUs, 8GB RAM. It's not sexy, but it's always on and costs a fraction of what equivalent serverless infrastructure would cost for always-running workloads. More on that later.

The runtime is Node.js with TypeScript everywhere. MongoDB handles persistence — it's flexible enough for the kind of heterogeneous data I'm storing (job listings, agent runs, conversation history, cron state), and I already know it well. No need to over-architect.

High-level stack

# Infrastructure
VPS: Hostinger (Ubuntu 24.04) — 4 vCPU / 8GB RAM
Process manager: PM2 (clustering, auto-restart, log rotation)
DB: MongoDB (local instance)

# Runtime
Node.js 22 + TypeScript
Zod for schema validation
Winston for structured logging

# AI
Anthropic SDK (Claude claude-sonnet-4-6 / claude-opus-4-5)
Multi-agent orchestration — custom runner

# Scheduling
Trigger.dev for complex workflows
node-cron for simple recurring tasks

# Interface
Telegram Bot API (node-telegram-bot-api)

PM2 is the glue that keeps everything alive. It handles process clustering, restarts on crash, and log rotation. I have a ecosystem.config.js that defines all the services and their restart policies. Dead simple, battle-tested.

Key Components

1. Telegram Bot — the primary interface

I access ClaudeUltra almost exclusively through Telegram. It's available on every device I own, notifications work reliably, and I can send commands from anywhere — client meetings, trains, wherever.

The bot handles three types of interactions: commands (slash-prefixed, trigger specific actions), natural language queries (routed to Claude with context), and notifications (system-initiated alerts). The command router is straightforward:

// Simplified command router
bot.on('message', async (msg) => {
  const text = msg.text ?? '';
  const chatId = msg.chat.id;

  if (!isAuthorized(chatId)) return; // whitelist only

  if (text.startsWith('/')) {
    const [command, ...args] = text.slice(1).split(' ');
    await handleCommand(command, args, chatId);
  } else {
    // Natural language — route to Claude with conversation history
    const response = await runAgentWithContext(text, chatId);
    await bot.sendMessage(chatId, response, { parse_mode: 'Markdown' });
  }
});

Authorization is a simple whitelist of Telegram chat IDs stored in env. Crude but effective — this thing has access to my systems, so I don't want any surprises.

2. Automated Freelance Prospection

This was the original reason I built the whole thing. The prospection pipeline runs on a schedule and does the following:

  • Scrapes Malt and Upwork for new missions matching my skills
  • Runs each listing through Claude to score relevance (0–10) based on my profile
  • Filters out missions below a threshold (currently 6/10)
  • Deduplicates against previously seen listings (MongoDB)
  • Sends Telegram alerts for anything worth looking at
// Relevance scoring prompt (simplified)
const prompt = `
You are evaluating a freelance mission for a senior fullstack developer
(React, Next.js, Node.js, TypeScript, React Native, AI integration)
based near Toulouse, France. Daily rate: 550–700€.

Mission: ${JSON.stringify(mission)}

Score this mission from 0 to 10 for relevance. Respond with JSON:
{ "score": number, "reason": string, "flags": string[] }

Flags: "remote_ok", "rate_match", "tech_match", "too_junior", "too_short"
`;

const result = await claude.messages.create({
  model: 'claude-haiku-3-5', // cheap model for scoring
  max_tokens: 256,
  messages: [{ role: 'user', content: prompt }],
});

I use Claude Haiku for the scoring step — it's fast, cheap, and more than capable for structured classification tasks. Sonnet and Opus are reserved for tasks that actually need the extra reasoning.

3. Cron-based Task Scheduling

Some tasks are simple recurring jobs (check job boards every 2 hours, send a morning digest). These run with node-cron. For more complex workflows with multiple steps, retries, and conditional branching, I use Trigger.dev.

// Simple cron — prospection check
cron.schedule('0 */2 * * *', async () => {
  logger.info('Running prospection scan...');
  try {
    await runProspectionPipeline();
  } catch (err) {
    logger.error('Prospection scan failed', { err });
    await notifyTelegram('⚠️ Prospection scan failed — check logs');
  }
});

// Morning digest at 8:00 AM Paris time
cron.schedule('0 8 * * *', async () => {
  await sendMorningDigest();
}, { timezone: 'Europe/Paris' });

4. Multi-Agent Orchestration

This is where it gets interesting. For complex tasks — writing a proposal, researching a potential client, generating a technical brief — I use a simple orchestrator that spawns sub-agents with specific instructions and tools.

The orchestrator maintains a task queue in MongoDB. Each task has a type, input, status, and output. Sub-agents pick up tasks, execute them using Claude with appropriate tools (web search, file write, Telegram send), and mark them complete.

// Simplified agent runner
async function runAgent(task: AgentTask): Promise<string> {
  const systemPrompt = AGENT_PROMPTS[task.type]; // task-specific system prompt
  const messages: MessageParam[] = [
    { role: 'user', content: task.input }
  ];

  let response = await claude.messages.create({
    model: task.model ?? 'claude-sonnet-4-6',
    max_tokens: 4096,
    system: systemPrompt,
    tools: getToolsForTask(task.type),
    messages,
  });

  // Agentic loop — handle tool calls
  while (response.stop_reason === 'tool_use') {
    const toolResults = await executeTools(response.content);
    messages.push(
      { role: 'assistant', content: response.content },
      { role: 'user', content: toolResults }
    );
    response = await claude.messages.create({
      model: task.model ?? 'claude-sonnet-4-6',
      max_tokens: 4096,
      system: systemPrompt,
      tools: getToolsForTask(task.type),
      messages,
    });
  }

  return extractTextFromResponse(response.content);
}

Challenges & Lessons

Long-running processes vs serverless

Serverless was the obvious first instinct — everyone uses it. But the economics don't work for always-on workloads. A VPS at ~15€/month vs. Lambda invocations for thousands of cron triggers and agent runs per month? VPS wins by a country mile.

The tradeoff is operational overhead: you manage the machine, you handle failures, you deal with OOM crashes. PM2 absorbs most of this — auto-restart, memory limits, log rotation. I also set up a simple uptime monitoring with BetterUptime that pings me on Telegram if the VPS goes dark.

Error handling and monitoring

The most important thing I learned: every cron job and agent run needs explicit error boundaries with Telegram notifications. Silent failures are a nightmare to debug days later. Everything is wrapped:

async function withErrorBoundary<T>(
  name: string,
  fn: () => Promise<T>
): Promise<T | null> {
  try {
    return await fn();
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    logger.error(`[${name}] Failed`, { err });
    await notifyTelegram(`❌ *${name}* failed:\n\`${message}\``);
    return null;
  }
}

Rate limiting and API costs

Claude API costs can sneak up on you if you're not careful. A few things that helped:

  • Use Haiku for classification and scoring tasks (10x cheaper than Sonnet)
  • Cache results aggressively — if a job listing was scored yesterday, don't score it again
  • Set hard max_tokens limits — most tasks don't need 8k tokens
  • Track spend in MongoDB with daily aggregation, alert on threshold

I also added a simple rate limiter in front of the Telegram natural language handler — max 20 queries per hour. Mostly to protect myself from accidentally kicking off expensive chains during testing.

Results

After running this for about two months, here's what the numbers look like:

~4h/week
saved on prospection and research
3× faster
proposal turnaround with AI drafts
€18/month
total infra cost (VPS + API)
99.7%
uptime over 60 days (PM2 + auto-restart)

The biggest win isn't the time saved — it's the cognitive load reduction. I no longer think about job boards. I get a Telegram message when something relevant pops up, I read it, I decide. That's it.

What's Next

A few things are on the roadmap:

  • Memory layer — right now each agent run starts fresh. I want to add a vector store so agents can retrieve relevant past context (previous client interactions, research notes, code snippets).
  • Auto-apply to missions — currently I still write proposals manually using the AI drafts as a base. The next step is a full auto-apply workflow for clearly matching missions, with human review before sending.
  • Browser automation — some job boards require login to see full listings. Playwright running headless on the VPS would unlock a lot of additional sources.
  • Better observability — I want a simple web dashboard (Next.js, naturally) to see agent run history, costs by day, and cron job status. Telegram-only monitoring works but it's not great for retrospectives.

If you're a freelancer thinking about building something similar — do it. The barrier to entry is lower than it looks. Start with one cron job that scrapes one job board and sends you a Telegram message. Get it running. Then iterate.

L'essentiel, c'est de commencer.

AG
Andy Garcia
Freelance Fullstack Developer · Toulouse, France

Building web apps, mobile products, and AI-powered tools for clients across Europe. I write about what I build and what I learn along the way.