> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Run an AI Log-Analysis Agent in an Upsun Task Container

> Reuse the cron-triggers-a-task pattern to run an OpenCode AI agent inside an Upsun task container. The agent reads your cron and application logs, analyzes them, and writes a Markdown health report to the task logs and a temporary file.

export const PostMeta = ({data = {}}) => {
  const {author, date} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const toSlug = value => String(value).toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const url = `/posts/authors/${toSlug(slug)}`;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna",
      "github": "jackrabbithanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "maz-mohammadi": {
      "name": "Maz Mohammadi"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
    </div>;
};

<PostMeta data={{ author: ["flovntp"], date: "2026-09-01T09:00:00.000Z", image: "/images/posts/tutorials/ai-log-analysis-agent-in-an-upsun-task-container/thumbnail.webp" }} />

> This is **Part 2** of the *Upsun Task Containers* series. In [Part 1](/posts/tutorials/offload-heavy-jobs-to-upsun-task-containers-with-cron/)
> we used a lightweight cron to trigger a [task container](/docs/configure-apps/tasks) for a heavy database job. Now we
> keep the exact same trigger pattern — but the task launches an **AI agent** instead of a shell command.

In Part 1, the task ran a deterministic command: reindex, import, export. That is the natural first step. But a task
container is also the ideal home for something less deterministic and more interesting: a **background AI agent** that
reads your project's logs, reasons about them, and reports back.

In this episode we build a small but real agent. On a schedule, a cron triggers a task container that runs
[OpenCode](https://opencode.ai/) over your cron and application logs. The agent summarizes what happened, flags
anomalies (slow crons, repeated errors, suspicious patterns), and writes a Markdown report — visible in the task logs
and saved to a temporary file. No issues created, no pull requests opened yet: just a clean, automated reading of your
logs. That groundwork unlocks the autonomous Auto-RCA agent we build in Part 3.

<Note>
  Task containers are currently in **prerelease**. To enable them on your project,
  [open this prepopulated support ticket](https://console.upsun.com/-/users/-/tickets/open?isGeneral=true\&category=feature_request_cat\&priority=normal\&subject=Enable%20task%20containers%20\(prerelease\)%20on%20my%20project\&description=Hello%2C%20I%27d%20like%20to%20enable%20task%20containers%20on%20my%20project.%0A%0AProject%20ID%3A%20%3CPROJECT_ID%3E)
  and add your project ID before submitting.
</Note>

## Prerequisites

Before starting, make sure you have:

* Completed [Part 1](/posts/tutorials/offload-heavy-jobs-to-upsun-task-containers-with-cron/), or are comfortable with
  the cron-triggers-a-task pattern
* An [Upsun account](https://auth.upsun.com/register) with task containers enabled
* The [Upsun CLI](/cli/install#install-the-upsun-cli) installed
* An API key for at least one LLM provider (for example Anthropic, OpenAI, or Google Gemini)
* Familiarity with [How to Host OpenCode on Upsun](/posts/ai/opencode-on-upsun/) and, optionally,
  [How to slash OpenCode Token costs by 90% on Upsun](/posts/ai/opencode-token-optimization/)

## The architecture

The flow is identical to Part 1 — a cron mints a token and triggers the task — except the task now runs a Node.js entry
point that drives OpenCode:

```mermaid theme={null}
flowchart LR
  A[Cron: hourly] -->|mint token + POST run| B[Upsun API]
  B -->|inject container| C[Task: nodejs + opencode]
  C -->|reads| D[/var/log/cron.log + app logs/]
  C -->|opencode run| E[LLM]
  E -->|report| F[Task logs + /tmp report]
```

The agent stays deliberately simple. It does not need the Upsun API token, GitHub access, or write permissions on your
repository — it only reads logs and produces text. We add those capabilities, step by step, in the next episodes.

## Step 1 - Define the agent task

The task runs a `nodejs` image and pulls in the `opencode-ai` package as a dependency so the `opencode` binary is
available at runtime. The [container profile](/docs/configure-apps/image-properties/container_profile) is bumped because
LLM agents are memory-hungry.

```yaml title=".upsun/config.yaml" theme={null}
tasks:
  loganalyzer:
    source:
      root: /
    type: nodejs:24
    container_profile: "HIGH_MEMORY"
    dependencies:
      nodejs:
        opencode-ai: "*"
    run:
      command: node agent/analyze.js
      timeout: 1800
    variables:
      env:
        OPENCODE_THEME: "opencode"
        HOME: "/app"
    hooks:
      build: |
        set -ex
        echo "Install Upsun Skills for OpenCode..."
        npx skills add https://github.com/upsun/ai --skill upsun -y
    relationships:
      app: "app:http"
```

Key points:

* **`dependencies.nodejs.opencode-ai`** installs the OpenCode CLI into the task image at build time, so `opencode run`
  works without a separate install step.
* **`hooks.build`** adds the official [Upsun OpenCode skills](https://github.com/upsun/ai). They are optional for pure
  log reading, but they make the agent Upsun-aware and set the stage for Part 3.
* **`container_profile: "HIGH_MEMORY"`** gives the agent enough memory to run a model session comfortably.
* **`relationships.app`** lets the agent reach the application over HTTP if you later want it to pull logs from an
  endpoint instead of the filesystem.

<Tip>
  The token-optimization plugins from [Part 2 of the OpenCode series](/posts/ai/opencode-token-optimization/) work here
  too. Reading log files is exactly the kind of output-heavy workload that benefits from `opencode-snip` — well worth
  wiring in once your agent goes beyond a proof of concept.
</Tip>

## Step 2 - Provide the LLM API key

OpenCode needs a provider key to talk to a model. Set it as a **sensitive** environment-level variable so it is injected
into the task container at run time but never committed:

```bash title="Terminal" theme={null}
upsun variable:create --level environment --name ANTHROPIC_API_KEY --sensitive 1
```

Sensitive variables are available to the task exactly as they are to applications — as environment variables — without
appearing in your configuration or logs.

## Step 3 - Write the agent

The agent is a small Node.js script. It collects the relevant log files, builds a focused prompt, runs OpenCode
non-interactively, and writes the resulting report both to the task logs (so it shows up in the activity feed) and to a
temporary file.

```javascript title="agent/analyze.js" theme={null}
#!/usr/bin/env node
'use strict';

const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');

// Log sources to inspect. Cron output lives in /var/log/cron.log; adjust the
// application log paths to match your framework.
const LOG_SOURCES = [
  '/var/log/cron.log',
  '/app/var/log/prod.log',
];

// Read the last N lines of each available log file, skipping missing ones.
function collectLogs(maxLines = 400) {
  const sections = [];
  for (const file of LOG_SOURCES) {
    if (!fs.existsSync(file)) {
      console.error(`Skipping missing log: ${file}`);
      continue;
    }
    const lines = fs.readFileSync(file, 'utf8').split('\n');
    const tail = lines.slice(-maxLines).join('\n');
    sections.push(`### ${file}\n\n${tail}`);
  }
  return sections.join('\n\n');
}

function buildPrompt(logs) {
  return [
    'You are a log-analysis agent running inside an Upsun task container.',
    'Analyse the cron and application logs below and write a concise health report.',
    '',
    'Your report MUST be valid Markdown with these sections:',
    '1. **Summary** — one paragraph on overall health.',
    '2. **Cron jobs** — for each cron seen, its outcome and duration if available.',
    '3. **Errors & anomalies** — repeated errors, stack traces, slow operations.',
    '4. **Recommendations** — concrete next steps, or "None" if all looks healthy.',
    '',
    'Be factual. Do not invent log lines. If a section has nothing to report, say so.',
    '',
    '## Logs',
    '',
    logs || '(no logs available)',
  ].join('\n');
}

function runOpenCode(prompt) {
  // OpenCode needs writable XDG dirs; /tmp is always writable in a task.
  const env = {
    ...process.env,
    XDG_DATA_HOME: '/tmp/opencode/data',
    XDG_CACHE_HOME: '/tmp/opencode/cache',
    XDG_STATE_HOME: '/tmp/opencode/state',
    XDG_CONFIG_HOME: '/tmp/opencode/config',
  };
  for (const dir of Object.values(env).filter((v) => v.startsWith('/tmp/opencode'))) {
    fs.mkdirSync(dir, { recursive: true });
  }

  const result = spawnSync('opencode', ['run', prompt], {
    encoding: 'utf8',
    env,
    maxBuffer: 32 * 1024 * 1024,
  });

  if (result.error) {
    console.error('Failed to start opencode:', result.error.message);
    process.exit(1);
  }
  return result.stdout ?? '';
}

const logs = collectLogs();
const report = runOpenCode(buildPrompt(logs));

// 1. Stream the report to the task logs (visible in the activity feed).
console.log('=== Log-analysis report ===');
console.log(report);
console.log('=== end report ===');

// 2. Persist it to a temporary file for the duration of the run.
const reportPath = path.join('/tmp', `log-report-${Date.now()}.md`);
fs.writeFileSync(reportPath, report);
console.log(`Report written to ${reportPath}`);
```

<Warning>
  `tmp` and `instance` mounts are **reset between runs**. The `/tmp` report exists only while the task runs and is meant
  for inspection in the same activity. To persist reports across runs, write to a `storage` mount or push them to a
  service. Part 3 shows how to push results out of the task entirely (a GitHub pull request).
</Warning>

## Step 4 - Trigger the agent from a cron

This is unchanged from Part 1: a tiny cron mints a short-lived token from the container-local broker and POSTs to the
task run endpoint. Reuse the same trigger script, pointed at the new task name:

```bash title="bin/trigger-loganalyzer.sh" theme={null}
#!/usr/bin/env bash
set -euo pipefail

token="$(curl -fsS http://localhost:8200/oauth2/token \
  -d grant_type=client_credentials | jq -r .access_token)"

curl -fsS -X POST \
  -H "Authorization: Bearer ${token}" \
  "https://api.upsun.com/projects/${PLATFORM_PROJECT}/environments/${PLATFORM_BRANCH}/tasks/loganalyzer/run"
```

And the cron plus the authorization that lets the app trigger the task:

```yaml title=".upsun/config.yaml" theme={null}
applications:
  app:
    type: php:8.5
    # ...
    authorizations:
      - type: task
        resource: loganalyzer
        action: operate
    crons:
      hourly-log-analysis:
        spec: '0 * * * *'
        commands:
          start: |
            if [ "$PLATFORM_ENVIRONMENT_TYPE" = "production" ]; then
              bash bin/trigger-loganalyzer.sh
            fi
```

## Step 5 - Deploy and read the report

Commit and push:

```bash title="Terminal" theme={null}
git add .upsun/config.yaml agent/analyze.js bin/trigger-loganalyzer.sh
git commit -m "Add AI log-analysis agent in a task container"
upsun push
```

Trigger a run manually to test it without waiting for the top of the hour:

```bash title="Terminal" theme={null}
upsun ssh -e main "bash bin/trigger-loganalyzer.sh"
```

Then open the activity for the task run and read the streamed report:

```bash title="Terminal" theme={null}
upsun activity:list --type environment.task -e main
upsun activity:log <activity-id> -e main
```

The agent's Markdown report appears between the `=== Log-analysis report ===` markers, and the path of the saved `/tmp`
copy is printed at the end.

## Wrapping up

You now have an AI agent that runs on a schedule, reads your logs, and writes you a health report — all inside an
ephemeral task container, with nothing extra running between invocations. The agent stayed read-only and self-contained,
which is exactly what you want before handing it more power.

In **Part 3**, we give the agent that power. Instead of reacting on a schedule, it reacts to **every 500 error** in
production: it analyzes the incident with the Upsun MCP server, opens a GitHub issue, and submits a pull request that
closes it. That is the full Auto-RCA agent — see you in the next episode.
