Promptfoo: LLM Red Teaming Against OWASP Top 10

If you ship an LLM-powered product and have not run a structured red team against it, you are flying blind on security. The OWASP LLM Top 10 2025 (released November 2024) now gives you a canonical list of attack categories to test against — and Promptfoo, the open-source tool that OpenAI acquired in March 2026 for its enterprise security reach, maps its 155 attack plugins directly to that list.

This guide walks through exactly how that mapping works, what a working YAML config looks like, and how to wire it into a CI pipeline before a bad actor does it for you.

What the OWASP LLM Top 10 2025 Actually Covers

The 2025 edition is a substantial revision from the 2023 original. Two new categories were added, several were renamed, and the ordering shifted to reflect real-world incident data from the intervening year.

Here is the full current list:

ID Category What Changed in 2025
LLM01 Prompt Injection Remains #1; now explicitly covers indirect injection via tool outputs and RAG context
LLM02 Sensitive Information Disclosure Moved up from #6; training-data extraction attacks elevated in severity
LLM03 Supply Chain Covers poisoned model weights, unsafe third-party plugins, and compromised fine-tune datasets
LLM04 Data and Model Poisoning Separated from Supply Chain to address runtime poisoning of RAG corpora
LLM05 Improper Output Handling XSS, SSRF, and command injection via unvalidated LLM output passed downstream
LLM06 Excessive Agency Agents with too many tools, too-broad permissions, or no human-in-the-loop
LLM07 System Prompt Leakage New 2025 entry; exposure of instructions, credentials, or logic in system prompts
LLM08 Vector and Embedding Weaknesses New 2025 entry; targets RAG vector DB poisoning and cross-tenant data leakage
LLM09 Misinformation Renamed from “Overreliance”; focuses on model-generated false information propagation
LLM10 Unbounded Consumption DoS through resource abuse, financial exploitation via inference flooding, model theft

The two new entries — LLM07 System Prompt Leakage and LLM08 Vector and Embedding Weaknesses — reflect how agentic architectures changed the threat surface. When your app has a system prompt that configures access to payment APIs or internal tools, leaking that prompt is a significant operational risk, not just an embarrassment.

Why Promptfoo Is the Right Tool

Before the OpenAI acquisition, Promptfoo was already used by more than 25% of Fortune 500 companies for LLM evaluation, according to OpenAI’s acquisition announcement. The open-source CLI has always been MIT-licensed and continues to be.

The core design decision is that Promptfoo separates adversarial probe generation from evaluation. This matters because:

  • Generating adversarial probes requires a “red team model” — an uncensored model that can write jailbreaks, injection payloads, and PII extraction attempts without refusal. Promptfoo Cloud handles this.
  • Evaluating those probes against your target runs locally, using your own API key, with no sensitive data sent to Promptfoo’s servers except the prompts themselves.

Effloow Lab inspected Promptfoo 0.121.11 (the latest release as of May 2026) by running npx promptfoo@latest redteam plugins, which outputs 155 attack plugins with their descriptions. We also ran a structural eval test with the built-in echo provider to verify the CLI works correctly without authentication. The full lab notes are at data/lab-runs/promptfoo-llm-red-teaming-owasp-agent-eval-guide-2026.md.

Mapping Plugins to OWASP Categories

The redteam plugins command shows every available attack generator. The OWASP mapping is not always explicit in the name, so here is the practical breakdown for the most important categories:

LLM01 — Prompt Injection

  • indirect-prompt-injection — tests injection via untrusted variables (retrieved document content, tool responses)
  • special-token-injection — Unicode tag-based instruction smuggling
  • cyberseceval — Meta’s CyberSecEval prompt injection dataset
  • pliny — community-curated jailbreak collection

LLM02 — Sensitive Information Disclosure

  • pii:direct — asks the model to output PII directly
  • pii:api-db — attempts to extract PII via API or database access
  • pii:session — cross-session PII leakage probes
  • pii:social — social engineering to extract personal data

LLM05 — Improper Output Handling

  • sql-injection — SQL injection via LLM output passed to a database
  • shell-injection — command injection via tool-calling LLMs
  • data-exfil — exfiltration via URL parameters, images, or Markdown links

LLM06 — Excessive Agency

  • excessive-agency — attempts to trigger actions beyond defined system boundaries

LLM07 — System Prompt Leakage

  • system-prompt-override — directly attempts to override or extract the system prompt

LLM09 — Misinformation

  • hallucination — checks whether the model generates false or fabricated information

For agentic applications, the coding-agent:* plugin family adds 12 additional attack surfaces specific to AI coding agents, including repo-prompt-injection, sandbox-write-escape, and secret-env-read.

A Working Red Team YAML Config

Here is a minimal config that covers six of the ten OWASP categories with a reasonable number of test cases for a weekly CI run:

# promptfooconfig.yaml
targets:
  - id: openai:gpt-4o-mini   # replace with your actual target
    label: prod-chatbot

prompts:
  - "{{input}}"

redteam:
  purpose: >
    A customer-support chatbot for a SaaS product. It can answer
    questions about billing, features, and documentation. It has
    read access to user account data and can initiate refunds.

  numTests: 20   # ~200 test cases total across all plugins

  plugins:
    # LLM01: Prompt Injection
    - indirect-prompt-injection
    - special-token-injection

    # LLM02: Sensitive Information Disclosure
    - pii:direct
    - pii:api-db

    # LLM05: Improper Output Handling
    - shell-injection
    - sql-injection

    # LLM06: Excessive Agency
    - excessive-agency

    # LLM07: System Prompt Leakage
    - system-prompt-override

    # LLM09: Misinformation
    - hallucination

  strategies:
    - basic            # standard adversarial prompts
    - jailbreak        # multi-step escalating attempts
    - prompt-injection # injected instructions in content fields

Two things worth noting about this config:

First, the purpose field is critical. Promptfoo uses it to tailor adversarial prompts to your specific application context. A generic purpose produces generic probes. A precise description — including what the system can access and what it is allowed to do — produces targeted attacks that actually match your threat model.

Second, numTests: 20 generates roughly 20 test cases per plugin across all three strategies. With nine plugins and three strategies, that is around 540 test cases. Adjust down for faster feedback during development, up for pre-release security gates.

Running the Scan

# One-time setup (no global install needed)
npx promptfoo@latest --version   # verify version

# Generate adversarial probes (requires Promptfoo account)
npx promptfoo@latest redteam generate 
  --config promptfooconfig.yaml 
  --output redteam.yaml

# Evaluate probes against your target
npx promptfoo@latest redteam run 
  --config promptfooconfig.yaml

# View results in web UI
npx promptfoo@latest view

The generate step calls Promptfoo Cloud to produce adversarial variants of your prompts. The actual evaluation runs locally against your specified target using your own API key. Results appear both in the terminal summary and in a local web UI at localhost:15500.

Note: redteam generate requires email verification and a Promptfoo account. Effloow Lab confirmed this during the PoC on 2026-05-20 — the CLI prompts for a work email before generating probes. The eval command (for running your own prompts with assertions) works without authentication.

Wiring It Into CI

Promptfoo ships an official GitHub Action for both basic eval and red team scanning. Here is a minimal security gate that runs on pull requests:

# .github/workflows/llm-security.yml
name: LLM Security Gate

on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'system-prompts/**'
      - '.env.example'

jobs:
  redteam:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run LLM red team
        uses: promptfoo/promptfoo-action@v2
        with:
          openai-api-key: ${{ secrets.OPENAI_API_KEY }}
          promptfoo-api-key: ${{ secrets.PROMPTFOO_API_KEY }}
          config: ./promptfooconfig.yaml
          type: redteam

      - name: Comment results on PR
        if: github.event_name == 'pull_request'
        run: |
          PASS_RATE=$(cat promptfoo-results.json | jq '.results.stats.passRate')
          echo "Pass rate: ${PASS_RATE}%"

The paths trigger is worth keeping narrow. Red team scans cost real money in LLM API calls — you want them running when prompt logic changes, not on every frontend commit.

For teams that want a scheduled baseline rather than per-PR gates, a cron trigger makes more sense:

on:
  schedule:
    - cron: '0 3 * * 1'   # Monday 3 AM UTC
  workflow_dispatch:

Interpreting Results

Promptfoo’s output classifies each test case as PASS or FAIL, but the severity classification matters as much as the pass rate. After a scan, the web UI groups findings by OWASP category and shows the specific prompts that triggered failures.

A few practical guidelines for triaging results:

Immediate fix (before next deploy): Any FAIL in pii:direct, system-prompt-override, or excessive-agency that includes an actual payload demonstration — not just a theoretical attack. These represent working exploits against your current deployment.

Fix in current sprint: Hallucination failures where the model confidently states false facts about your product, pricing, or policies. These are reputation and liability risks even if not security exploits.

Review next sprint: Indirect prompt injection failures that require a contrived multi-step scenario. Prioritize based on whether your application ingests untrusted external content (RSS feeds, user-submitted documents, web browsing).

Track as known risk: Failures in categories your application explicitly does not need to handle — for example, a code generation assistant may intentionally produce shell commands that would fail a shell-injection assertion by design.

What Works Well
<ul>
  <li>155 plugins cover a wide attack surface with minimal config</li>
  <li>YAML-first config is version-controllable and reviewable in PRs</li>
  <li>Local evaluation means sensitive prompts stay in your infrastructure</li>
  <li>GitHub Action integrates in under 30 lines</li>
  <li>Echo provider lets you validate config structure without API costs</li>
</ul>


What to Watch
<ul>
  <li>Adversarial probe generation requires Promptfoo account (email verification)</li>
  <li>Full scans with 20+ plugins run hundreds of LLM calls — budget accordingly</li>
  <li>The <code>owasp:llm</code> meta-preset is referenced in docs but resolves server-side</li>
  <li>Post-OpenAI acquisition, enterprise pricing direction is unclear</li>
  <li>False positives increase with broader <code>purpose</code> descriptions</li>
</ul>

The Agentic AI Extension

OWASP released a separate Top 10 for Agentic Applications in December 2025, announced at Black Hat Europe. Promptfoo maps its coding-agent:* plugin family and the broader agentic:* namespace to this list.

The key risks specific to agents that do not appear in the standard LLM Top 10:

  • Memory poisoning (agentic:memory-poisoning) — injecting false data into an agent’s persistent memory store
  • Automation hijacking (coding-agent:automation-poisoning) — modifying CI scripts, hooks, or scheduled jobs to persist unsafe behavior after the immediate task completes
  • Sandbox escape (coding-agent:sandbox-write-escape, coding-agent:sandbox-read-escape) — reading or writing outside the intended workspace
  • Delayed exfiltration (coding-agent:delayed-ci-exfil) — planting workflow changes that leak data after the evaluation run completes

If your application uses tool-calling or multi-step planning, run both the standard OWASP LLM config and the agentic plugin set.

Frequently Asked Questions

Q: Do I need a Promptfoo account to use it at all?

No. The promptfoo eval command — which runs assertion-based tests against any provider using your own prompts — works without authentication. You only need an account for promptfoo redteam generate, which uses Promptfoo’s cloud models to generate adversarial probes. You can hand-write test cases in a redteam.yaml and run promptfoo eval against them without ever signing up.

Q: How does Promptfoo compare to Microsoft PyRIT or Garak?

PyRIT is a Python framework from Microsoft’s AI Red Team, better suited to researchers writing custom attack logic. Garak is similarly research-oriented with strong dataset coverage but no CI integration. Promptfoo sits in the practitioner tier: less flexible than PyRIT for novel research, but far easier to integrate into a standard dev workflow via YAML and GitHub Actions.

Q: Does the OpenAI acquisition change anything for open-source users?

As of May 2026, the repo remains MIT-licensed and the CLI is fully functional. OpenAI’s stated intent is to integrate Promptfoo’s technology into its Frontier enterprise platform while keeping the open-source tool available. Whether that changes pricing or rate limits for the cloud generation service is not yet public.

Q: What is the minimum viable config for a solo developer?

Three plugins cover the most commonly exploited categories with a reasonable test count:

plugins:
  - indirect-prompt-injection
  - pii:direct
  - excessive-agency
strategies:
  - basic

Run this weekly with numTests: 10 per plugin. That is 30–90 API calls depending on strategy expansion — cheap enough to run regularly, targeted enough to catch the most common issues.

Key Takeaways

The OWASP LLM Top 10 2025 gives you a peer-reviewed threat model. Promptfoo gives you an automated way to test against it. The combination works because Promptfoo’s plugin taxonomy was built with OWASP categories in mind, and the YAML config format makes security testing a first-class part of your repository rather than a one-off audit.

The practical path for most teams:

  1. Add promptfooconfig.yaml to your repo with the plugins that match your threat model
  2. Run promptfoo eval on every PR that modifies prompt logic (no auth required)
  3. Run promptfoo redteam run on a weekly schedule or before major releases
  4. Triage FAIL results by category, starting with PII and system prompt leakage

Bottom Line

Promptfoo 0.121 is the most practical path from “we should test our LLM app for security” to “we have a CI gate that runs 500+ adversarial probes against OWASP categories on every release.” The echo provider and local eval work without any account; the full red team needs a Promptfoo login but remains the fastest way to get an OWASP LLM Top 10 scan report on an LLM-powered application.

Stop Crashing the Browser: Server-Side Data Grids in Next.js ⚡

The Client-Side Fetching Disaster

Data tables are the most common UI element in any B2B SaaS dashboard. When tasked with building a “Client Roster” grid, many React developers reach for heavy client-side libraries. They fetch all 5,000 client records from the API, store them in a massive useState array, and let the browser’s JavaScript engine handle the sorting, filtering, and pagination.

For 100 rows, this is blazing fast. For 50,000 rows, this architecture is a disaster. You force the user to download a 5MB JSON payload. Their browser tab consumes massive amounts of RAM, the UI stutters during sorting, and mobile devices completely crash. At Smart Tech Devs, we never send unnecessary data to the client. We architect Server-Side Data Grids using Next.js Server Components.

The Architecture: URL-Driven Server Components

To build a high-performance grid, the database must do the heavy lifting. The frontend’s only job is to update the URL parameters. The Next.js Server Component reads those parameters, executes a highly optimized SQL query (with LIMIT and OFFSET), and sends exactly 10 rows of pure HTML to the browser.

Step 1: The Server Component Core

In the Next.js App Router, the page.tsx automatically receives the searchParams from the URL. We pass these directly to our database fetcher.


// app/dashboard/clients/page.tsx
import { Suspense } from 'react';
import db from '@/lib/db';
import ClientTable from './components/ClientTable';
import TableSkeleton from './components/TableSkeleton';

export default async function ClientsPage({
    searchParams,
}: {
    searchParams: { q?: string; sort?: string; page?: string };
}) {
    // 1. Extract URL state with safe defaults
    const query = searchParams?.q || '';
    const sort = searchParams?.sort || 'created_at_desc';
    const currentPage = Number(searchParams?.page) || 1;
    const perPage = 10;

    // 2. Perform the heavy lifting securely on the server
    const clients = await db.client.findMany({
        where: {
            name: { contains: query, mode: 'insensitive' },
        },
        orderBy: {
            [sort.split('_')[0]]: sort.split('_')[1], // e.g., created_at: 'desc'
        },
        skip: (currentPage - 1) * perPage,
        take: perPage,
    });

    const totalCount = await db.client.count({
        where: { name: { contains: query, mode: 'insensitive' } }
    });

    return (
        <main>
            <h1>Client Roster</h1>
            <Suspense fallback={<TableSkeleton />}>
                {/* We pass ONLY the 10 rows of data to the UI component */}
                <ClientTable data={clients} totalPages={Math.ceil(totalCount / perPage)} />
            </Suspense>
        </main>
    );
}

Step 2: The Client Component UI

The ClientTable component doesn’t need to know how to sort data. It just needs to update the browser URL when a user clicks a column header. Next.js will automatically re-run the Server Component and stream the new rows in.


// app/dashboard/clients/components/ClientTable.tsx
"use client";

import { useRouter, usePathname, useSearchParams } from 'next/navigation';

export default function ClientTable({ data, totalPages }) {
    const router = useRouter();
    const pathname = usePathname();
    const searchParams = useSearchParams();

    const handleSort = (column: string) => {
        const params = new URLSearchParams(searchParams);
        
        // Toggle sort direction
        const currentSort = params.get('sort');
        const newSort = currentSort === `${column}_asc` ? `${column}_desc` : `${column}_asc`;
        
        params.set('sort', newSort);
        router.push(`${pathname}?${params.toString()}`);
    };

    return (
        <table className="w-full">
            <thead>
                <tr>
                    <th onClick={() => handleSort('name')} className="cursor-pointer">Name ↕</th>
                    <th onClick={() => handleSort('status')} className="cursor-pointer">Status ↕</th>
                </tr>
            </thead>
            <tbody>
                {data.map(client => (
                    <tr key={client.id}>
                        <td>{client.name}</td>
                        <td>{client.status}</td>
                    </tr>
                ))}
            </tbody>
        </table>
    );
}

The Engineering ROI

By keeping data grids server-side, you completely decouple your frontend performance from the size of your database. Whether your client has 100 customers or 10 million, the browser only ever downloads exactly 10 rows of data. This guarantees a blazing-fast, crash-proof user experience on any device.

I read the OpenClaw thread everyone shared — these 5 fixes cut agent costs to one-third and stopped the loops

I read the OpenClaw thread everyone shared — these 5 fixes cut agent costs to one-third and stopped the loops

I clicked into a popular r/openclaw thread expecting the usual advice: tweak the prompt, pick a smarter model, maybe add more context.

Instead, the OP described the exact failure mode a lot of us hit when we move from demos to always-on agents:

  • Claude Opus 4.6 handling cheap background work
  • vague completion criteria
  • retries with no hard stop
  • state living inside prompts instead of durable storage
  • loops burning money while doing almost nothing useful

The useful part was that this wasn’t one silver bullet. It was a stack of practical fixes.

And the biggest one was brutally simple:

stop sending cheap work to expensive models

According to the thread, moving heartbeat checks, cron pings, and other low-value supervision off Claude Opus cut spend to about one-third.

That tracks with what I keep seeing in OpenClaw, n8n, Make, Zapier, and custom worker setups. The expensive part usually isn’t the main reasoning step. It’s the invisible scaffolding around it.

If you’re building long-running agents, these 5 fixes are worth stealing.

The pattern: cost problems start as reliability problems

Agents rarely become expensive because one prompt was huge.

They become expensive because a workflow can’t confidently tell whether it succeeded.

Then it retries.

Then it retries again.

Then it does all of that on Claude Opus 4.6.

That’s how you end up paying premium-model rates for what is basically daemon maintenance.

A rough version of the bad pattern looks like this:

while (!done) {
  const result = await callModel({
    model: "claude-opus-4-6",
    prompt: `Check whether the job completed. If not, decide what to do next. Context: ${hugeContext}`
  })

  if (result.saysDone) {
    done = true
  } else {
    await sleep(30000)
  }
}

This looks fine in testing.

It gets ugly when it runs 24/7.

Fix 1: Stop using Claude Opus for heartbeat checks and cron pings

This was the clearest lesson from the thread.

Claude Opus 4.6 is great for hard reasoning. It is a bad choice for cheap supervision.

Tasks that usually should not hit your most expensive model:

  • heartbeat checks
    n- cron-trigger validation
  • retry bookkeeping
  • simple routing
  • status classification
  • watchdog logic
  • “did this step finish?” checks

If the task is basically classification or state inspection, use a cheaper layer.

A cleaner architecture looks more like this:

async function routeTask(task: Task) {
  if (task.type === "heartbeat") {
    return lightweightCheck(task)
  }

  if (task.type === "status_check") {
    return gpt54StatusCheck(task)
  }

  if (task.type === "deep_reasoning") {
    return claudeOpusDecision(task)
  }

  if (task.type === "synthesis") {
    return grok420Synthesis(task)
  }
}

That’s the right mental model: model triage.

Not loyalty.

Not “send everything to the smartest model.”

Just match cost to task difficulty.

My take

The loser here is the all-Claude-Opus architecture. It feels elegant until you realize your agent is using a premium model to narrate its own retries.

If a task could be implemented as a boolean check, a rules engine, or a cheap classifier, don’t wrap it in expensive reasoning.

Fix 2: Add explicit success criteria or the agent will loop forever

A lot of agent loops are just weak definitions of done.

Bad:

  • “make sure the sync worked”
  • “confirm the task completed”
  • “retry if needed”

Better:

  • file exists at expected path
  • API returned HTTP 200
  • row count increased by 1
  • webhook delivered with matching job ID
  • CRM record status changed to processed

The thread’s OP improved reliability by making completion verifiable instead of interpretive.

That’s the difference between an agent that finishes and an agent that keeps thinking out loud.

Example:

async function verifyJobComplete(jobId: string) {
  const res = await fetch(`https://api.example.com/jobs/${jobId}`)
  const job = await res.json()

  return job.status === "completed" && job.output_url != null
}

Then your loop becomes:

for (let attempt = 1; attempt <= 5; attempt++) {
  await runStep(jobId)

  const ok = await verifyJobComplete(jobId)
  if (ok) return { success: true }

  await sleep(5000)
}

return { success: false, reason: "verification_failed_after_5_attempts" }

That’s boring code.

Boring is good.

Boring code is cheaper than “agent intuition.”

Fix 3: Put anti-loop rules in code, not just prompts

If your only loop prevention is “please do not retry excessively,” you do not have loop prevention.

You have wishful thinking.

Hard limits matter:

  • max retries per step
  • max retries per job
  • cooldown windows
  • duplicate action detection
  • dead-letter queue for stuck runs
  • escalation path to human review

A practical pattern:

const MAX_STEP_RETRIES = 3
const MAX_JOB_RETRIES = 10

async function shouldRetry(state: WorkflowState) {
  if (state.stepRetries >= MAX_STEP_RETRIES) return false
  if (state.jobRetries >= MAX_JOB_RETRIES) return false
  if (state.lastError === "invalid_input") return false
  return true
}

And log retry reasons explicitly:

{
  "jobId": "job_123",
  "step": "sync_customer",
  "retry": 2,
  "reason": "webhook_timeout",
  "nextAttemptInSeconds": 30
}

This is where a lot of teams get lazy. They let the model decide whether another retry “feels right.”

Don’t do that.

Retries are control flow. Control flow belongs in code.

Fix 4: Store state in Redis or Postgres instead of re-prompting old context

This one matters a lot for long-running OpenClaw jobs.

If an agent made a decision, store it somewhere durable.

Don’t keep shoving the same history back into the prompt and hope compaction preserves the important part.

That approach fails first when your workflow crosses tools.

A realistic automation might look like this:

  1. OpenClaw decides to start a task
  2. n8n waits for a webhook
  3. Make transforms the payload
  4. Zapier updates Salesforce or HubSpot
  5. the agent wakes up six minutes later and needs to resume

If the only memory is inside a shrinking prompt window, drift is inevitable.

If the state is in Redis or Postgres, the agent can resume from facts.

Redis example

import Redis from "ioredis"

const redis = new Redis(process.env.REDIS_URL!)

async function saveWorkflowState(jobId: string, state: object) {
  await redis.set(`workflow:${jobId}`, JSON.stringify(state), "EX", 86400)
}

async function loadWorkflowState(jobId: string) {
  const raw = await redis.get(`workflow:${jobId}`)
  return raw ? JSON.parse(raw) : null
}

Postgres example

create table workflow_state (
  job_id text primary key,
  status text not null,
  last_decision jsonb not null,
  retry_count integer not null default 0,
  updated_at timestamptz not null default now()
);

Then your agent prompt can stay small and focused:

Job status: awaiting_webhook
Last decision: wait for provider callback
Retry count: 1
Next action options: [poll_status, mark_failed, continue_waiting]

That’s much better than pasting 4,000 tokens of historical narration back into every call.

My take

A lot of teams pay premium model costs to compensate for weak state handling.

That’s backwards.

Better state is cheaper than better prompting.

Fix 5: Separate orchestration from reasoning

This is the architectural version of the first four fixes.

Use code for orchestration.
Use models for reasoning.

Not the other way around.

Your worker should own:

  • retries
  • scheduling
  • idempotency
  • state transitions
  • timeout handling
  • webhook correlation
  • rate limiting

Your model should own:

  • ambiguous classification
  • planning when rules are insufficient
  • summarization
  • extraction when structure is messy
  • non-trivial decision-making

A simple split:

async function processJob(job: Job) {
  const state = await loadWorkflowState(job.id)

  switch (state.status) {
    case "awaiting_classification":
      return classifyWithGPT54(job)

    case "awaiting_complex_decision":
      return decideWithClaudeOpus(job)

    case "awaiting_status_check":
      return pollProviderAPI(job)

    case "awaiting_synthesis":
      return synthesizeWithGrok(job)

    default:
      throw new Error(`Unknown state: ${state.status}`)
  }
}

This is less magical than “autonomous agent does everything.”

It’s also much more reliable.

What changed after these fixes

The thread’s reported result was the kind of improvement that actually changes workflow design:

  • spend dropped to about one-third
  • loops were reduced
  • reliability improved
  • long-running jobs stopped losing the plot

That sequence makes sense.

First, move cheap recurring work off expensive models.
Then define what success actually means.
Then stop retries from becoming infinite.
Then give the agent durable state.

Once you do that, you stop paying for confusion.

The practical checklist

If you’re running OpenClaw agents or similar automations, here’s the checklist I’d use:

Fix What to do
Model triage Keep Claude Opus 4.6 for hard reasoning. Use GPT-5.4 or cheaper logic for status checks, routing, and supervision.
Verifiable completion End every important step with a testable success condition.
Anti-loop controls Set max retries, cooldowns, duplicate detection, and dead-letter handling in code.
Durable state Store decisions in Redis, Postgres, or OpenClaw memory features instead of bloating prompts.
Orchestration split Let code manage workflow control flow; let models handle actual reasoning.

Why this matters more under per-token billing

This is the part people notice late.

Per-token pricing punishes exactly the kind of behavior serious automations need:

  • watchdog checks
  • retries
  • polling
  • long-running supervision
  • cross-tool coordination

In a chat app, one bad retry is annoying.

In OpenClaw, n8n, Make, Zapier, or a custom queue, one bad retry pattern can run every few minutes forever.

That’s why predictable pricing matters more as agents get more useful.

The more background calls your system needs, the worse token anxiety gets.

If you’re running agents continuously, a flat-cost API setup is often a better fit than metering every tiny supervision call. Standard Compute is interesting here because it keeps the OpenAI-compatible API shape developers already use, but swaps per-token pricing for a predictable monthly cost. That makes a lot more sense for always-on automations than staring at usage charts and hoping your watchdog logic behaves.

Final thought

The best part of that OpenClaw thread was that it didn’t pretend the answer was “just use a smarter model.”

It was the opposite.

Use Claude Opus 4.6 when the task deserves Claude Opus 4.6.
Use GPT-5.4 for lighter decisions.
Use Grok 4.20 when synthesis is the actual job.
And don’t ask premium models to babysit your infrastructure.

If a workflow can’t prove it finished, it will eventually loop.
If state only lives in prompts, it will eventually drift.
If retries are controlled by vibes, they will eventually get expensive.

That’s not just an OpenClaw lesson.

That’s the operating manual for any long-running AI automation.

If you’re building one right now, start by auditing every model call that happens when nothing interesting is happening.

That’s usually where the money is going.

Stop Worker Crashes: Fixing Laravel Queue Memory Leaks in Production

The Silent Killer of Background Jobs

When you deploy a B2B SaaS platform at Smart Tech Devs, your Laravel queues handle the heaviest lifting—processing CSV imports, generating PDF reports, and sending thousands of emails. To process these efficiently, we use the php artisan queue:work command. This runs the worker as a daemon process, meaning the PHP script boots up once and stays alive in memory to process job after job without the overhead of rebooting the framework.

However, this speed comes with a dangerous architectural trade-off: Memory Leaks. Because the PHP process never dies, static variables, singleton instances, and Eloquent model caches slowly accumulate in RAM. Over a few hours (or days), your worker hits the PHP memory limit (usually 128MB or 256MB) and violently crashes with a Allowed memory size exhausted fatal error.

The Enterprise Solution: Graceful Restarts

While you should absolutely profile your code to fix severe leaks, minor memory accumulation in a long-running PHP daemon is almost inevitable. The standard DevOps solution is not to endlessly increase your server’s RAM, but to architect Graceful Restarts.

Instead of letting the worker run until it explodes, we instruct Laravel to automatically kill the worker process safely after it hits a specific threshold. Because we use a process monitor like Supervisor on our Ubuntu VPS, the moment the worker dies, Supervisor instantly spawns a fresh, clean process with zero memory bloat.

Configuring Supervisor for Scalability

In Laravel, we control these thresholds using two powerful flags: --max-jobs and --max-time.


# /etc/supervisor/conf.d/laravel-worker.conf

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
# The command tells the worker to die after 1,000 jobs OR 1 hour (3600 seconds)
command=php /var/www/smarttechdevs.com/artisan queue:work --max-jobs=1000 --max-time=3600 --tries=3 --timeout=90
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=forge
numprocs=8 # Run 8 parallel workers
redirect_stderr=true
stdout_logfile=/var/www/smarttechdevs.com/storage/logs/worker.log
stopwaitsecs=3600

Why This Architecture Wins

Let’s break down why this specific command (--max-jobs=1000 --max-time=3600) is the gold standard for backend stability:

  • Predictable Memory: By forcing the PHP process to restart after 1,000 jobs, you guarantee the garbage collector flushes the RAM entirely. The worker never lives long enough to hit the fatal memory limit.
  • Safety First: Unlike a hard crash, Laravel waits for the current job to finish successfully before exiting. No data is lost or corrupted mid-process.
  • Zero Downtime: Supervisor detects the exit code and spins up the replacement worker in milliseconds. Your queue throughput remains massive, but your infrastructure stays perfectly healthy.

Conclusion

A reliable SaaS backend shouldn’t require developers to wake up at 3 AM to restart failed queue workers. By acknowledging the reality of PHP memory management and architecting graceful restarts via Supervisor, you build a self-healing infrastructure capable of processing millions of background jobs effortlessly.

78 tarot cards x 5 languages: the content pipeline nobody warns you about

Last week I shipped Tarotas, a tarot reading app that supports five languages from day one. Czech, English, Polish, Slovak, German. 78 cards in each. That’s 390 card objects, and each card carries a name, upright meaning, reversed meaning, a set of keywords, and a one-paragraph description. Multiply it out and you’re looking at roughly 2,000 individual text fields.

I didn’t translate them by hand. Obviously.

The source language problem

My first instinct was to write everything in English and translate outward. Wrong call. Tarot has deep roots in specific traditions, and the English terms aren’t always the “canonical” ones across languages. The Czech name for The High Priestess is “Velekněžka,” and if you translate back from English you get something slightly off.

So I wrote the Czech versions first (that’s the language I think in), then generated the other four from Czech as source. This sounds backwards if you’re used to EN-first localization, but it meant the source text had the most nuance and the translations could be validated against it.

AI translation with a trust boundary

I used Claude for the bulk translations. The pipeline was dead simple: feed it the Czech card object as JSON with all fields, ask for the target language, get back a JSON object with the same structure. One card at a time, not batched, because batching 78 cards in one prompt led to skipped fields and hallucinated keywords.

The prompt looked like this:

Translate this tarot card from Czech to {lang}.
Keep the JSON structure identical.
For the card name, use the standard tarot name
in {lang} tradition.
For keywords, translate meaning not words.

That last line matters. “Nový začátek” in Czech literally means “new beginning,” but the German tarot tradition might use “Aufbruch” (departure, setting out) for the same card. I wanted the AI to pick the term a German tarot reader would actually recognize.

Where the human still wins

I didn’t review all 2,000 fields. That would defeat the purpose. Instead I set up three checkpoints:

  1. Card names. I manually verified every Major Arcana name (22 cards x 5 languages = 110 fields). These are the ones people actually search for and would notice if wrong.

  2. Reversed meanings spot-check. Reversed card meanings are where AI gets creative. “Stagnation” becomes “creative blockage” becomes “spiritual paralysis” if you don’t catch it early. I checked 10 random Minor Arcana per language.

  3. Keyword deduplication. The AI sometimes generates near-synonyms as separate keywords. “Love” and “romance” on the same card, that kind of thing. I wrote a quick script to flag cards where two keywords had high semantic similarity.

Total human review time: about 4 hours. For 2,000 fields across 5 languages, I can live with that.

The storage side was simple. Postgres (via Supabase), one row per card-language pair:

create table cards (
  id uuid primary key,
  card_number int not null,
  arcana text not null,
  suit text,
  lang text not null,
  name text not null,
  meaning_upright text,
  meaning_reversed text,
  keywords text[],
  description text,
  unique(card_number, lang)
);

390 rows. No joins for a card lookup: where card_number = X and lang = Y. The app reads the browser language or the user’s explicit choice, passes it as a parameter, done. If I ever add language number six, it’s 78 inserts and zero schema changes.

What it actually cost

The AI translation ran about $3 in API calls. 390 requests, a few hundred tokens each. The human review: 4 hours of my time spread over two evenings. The keyword dedup script: 30 minutes to write, ran in under a second.

I got quotes for professional tarot translation. $0.08 to $0.12 per word, because it’s a niche. At roughly 50 words per card across all fields, that’s 78 x 50 x 5 x $0.10 = about $1,950. For a product that has 37 visitors so far. Nope.

The AI route isn’t flawless. I’m sure a native Polish tarot reader could find something to nitpick. But “mostly right with a clear correction path” beats “can’t afford to ship multilingual at all” every single time.

Content scaling with AI is maybe 10% prompting and 90% knowing where the AI will screw up. For tarot, that’s proper nouns (card names vary between traditions) and nuanced spiritual terminology (reversed meanings). For your domain, it’ll be something completely different. But the pattern holds: translate structured data one object at a time, validate the high-stakes fields manually, accept “good enough” for the rest.

I shipped five languages in a week. The app is live at tarotas.com. It’s early, it’s small, and I still can’t guarantee the German keywords are perfect. But 390 content units are out in the world instead of sitting in a “someday we’ll translate” backlog forever.

Jakub, builder @ Inithouse

Synology NAS에 OpenClaw 설치하기

들어가며

이번 글에서는 Synology NAS에 OpenClaw를 Docker Compose 기반으로 신규 설치하는 과정을 정리한다.

Synology DSM 환경은 일반적인 Ubuntu 서버와 다르다. NAS에 직접 Node.js/npm 기반으로 OpenClaw를 설치할 수도 있지만, 장기 운영 관점에서는 Docker Compose 기반 구성이 더 깔끔하다.

Docker Compose로 구성하면 다음 장점이 있다.

  • OpenClaw 실행 환경을 Synology host와 분리할 수 있음
  • gateway와 CLI 실행 환경을 일관되게 유지할 수 있음
  • 재부팅 후 자동 실행을 restart: unless-stopped로 처리할 수 있음
  • .openclaw 설정과 workspace를 volume으로 분리해 보존할 수 있음
  • GitHub CLI, ripgrep, jq 같은 코딩 작업용 도구를 이미지에 함께 포함할 수 있음
  • Synology 터미널의 TTY/리치 출력 문제를 우회하기 쉬움

최종 구성

최종 디렉터리 구조는 다음과 같이 잡는다.

/volume1/docker/openclaw
├── docker-compose.yml
├── Dockerfile
├── .env
└── .openclaw/
    ├── workspace/
    ├── logs/
    └── cron/

컨테이너 내부에서는 OpenClaw 설정 경로를 다음처럼 사용한다.

/home/node/.openclaw
/home/node/.openclaw/workspace

즉, NAS host의 실제 경로는 다음이지만,

/volume1/docker/openclaw/.openclaw

컨테이너 내부에서는 다음 경로로 보이게 한다.

/home/node/.openclaw

이 구조의 핵심은 다음과 같다.

  • OpenClaw 설정과 workspace는 NAS의 /volume1/docker/openclaw/.openclaw에 보존
  • 컨테이너는 언제든 삭제/재생성 가능
  • OpenClaw CLI는 host에 직접 설치하지 않고 openclaw-cli 컨테이너로 실행
  • OpenClaw gateway는 openclaw-gateway 컨테이너로 상시 실행

사전 준비

Synology NAS에는 DSM의 Container Manager가 설치되어 있어야 한다.

SSH 터미널에서 Docker와 Compose가 정상 동작하는지 확인한다.

sudo docker version
sudo docker compose version

Synology에서는 일반 사용자 계정이 Docker daemon socket에 직접 접근하지 못하는 경우가 많다.

확인:

ls -al /var/run/docker.sock

예시:

srw-rw---- 1 root root 0 docker.sock

이 경우 일반 사용자로는 docker pull 같은 명령이 실패한다.

따라서 이 글에서는 모든 Docker 명령을 다음처럼 sudo 기준으로 실행한다.

sudo docker compose ...

설치 디렉터리 생성

OpenClaw Compose 프로젝트용 디렉터리를 만든다.

sudo mkdir -p /volume1/docker/openclaw
cd /volume1/docker/openclaw

OpenClaw 설정과 workspace를 저장할 .openclaw 디렉터리도 미리 만든다.

sudo mkdir -p /volume1/docker/openclaw/.openclaw
sudo mkdir -p /volume1/docker/openclaw/.openclaw/workspace
sudo mkdir -p /volume1/docker/openclaw/.openclaw/logs
sudo mkdir -p /volume1/docker/openclaw/.openclaw/cron

권한 정리

OpenClaw Docker 이미지는 컨테이너 내부에서 node 사용자로 실행된다. 일반적으로 이 사용자의 UID는 1000이다.

따라서 host의 .openclaw 디렉터리를 컨테이너 내부 사용자가 쓸 수 있도록 권한을 맞춘다.

sudo chown -R 1000:1000 /volume1/docker/openclaw/.openclaw

권한이 맞지 않으면 다음과 같은 에러가 발생할 수 있다.

EACCES: permission denied, open '/home/node/.openclaw/logs/config-health.json'

커스텀 Dockerfile 작성

OpenClaw 기본 이미지만으로도 gateway 실행은 가능하다. 하지만 코딩 에이전트 용도로도 사용할 계획이라면 gh, ripgrep, jq를 함께 넣어두는 것이 편하다.

/volume1/docker/openclaw/Dockerfile

FROM ghcr.io/openclaw/openclaw:latest

USER root

RUN apt-get update 
  && apt-get install -y --no-install-recommends gh ripgrep jq 
  && rm -rf /var/lib/apt/lists/*

USER node

각 도구의 용도는 다음과 같다.

  • gh: GitHub CLI
  • ripgrep: 빠른 코드 검색용 rg
  • jq: JSON 출력 확인 및 가공

.env 작성

/volume1/docker/openclaw/.env

OPENCLAW_CONFIG_DIR=/volume1/docker/openclaw/.openclaw
OPENCLAW_WORKSPACE_DIR=/volume1/docker/openclaw/.openclaw/workspace

OPENCLAW_GATEWAY_PORT=18789
OPENCLAW_TZ=Asia/Seoul

# GitHub CLI 또는 GitHub Copilot 연동이 필요할 경우 입력
GH_TOKEN=
GITHUB_TOKEN=
COPILOT_GITHUB_TOKEN=

.env에는 GitHub token, Copilot token 같은 민감정보가 들어갈 수 있으므로 권한을 제한한다.

sudo chmod 600 /volume1/docker/openclaw/.env

docker-compose.yml 작성

/volume1/docker/openclaw/docker-compose.yml

services:
  openclaw-gateway:
    build:
      context: .
      dockerfile: Dockerfile
    image: local/openclaw-gh:latest
    container_name: openclaw-gateway
    env_file:
      - .env
    environment:
      HOME: /home/node
      OPENCLAW_HOME: /home/node
      TERM: xterm-256color

      OPENCLAW_STATE_DIR: /home/node/.openclaw
      OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
      OPENCLAW_CONFIG_DIR: /home/node/.openclaw
      OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace

      OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-}
      OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
      OPENCLAW_DISABLE_BONJOUR: ${OPENCLAW_DISABLE_BONJOUR:-}

      GH_TOKEN: ${GH_TOKEN:-}
      GITHUB_TOKEN: ${GITHUB_TOKEN:-}
      COPILOT_GITHUB_TOKEN: ${COPILOT_GITHUB_TOKEN:-}

      TZ: ${OPENCLAW_TZ:-Asia/Seoul}
    volumes:
      - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
      - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
    ports:
      - "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
    extra_hosts:
      - "host.docker.internal:host-gateway"
    cap_drop:
      - NET_RAW
      - NET_ADMIN
    security_opt:
      - no-new-privileges:true
    init: true
    restart: unless-stopped
    command:
      [
        "node",
        "dist/index.js",
        "gateway",
        "--bind",
        "lan",
        "--port",
        "18789"
      ]
    healthcheck:
      test:
        [
          "CMD",
          "node",
          "-e",
          "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
        ]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s

  openclaw-cli:
    image: local/openclaw-gh:latest
    network_mode: "service:openclaw-gateway"
    env_file:
      - .env
    environment:
      HOME: /home/node
      OPENCLAW_HOME: /home/node
      TERM: xterm-256color

      OPENCLAW_STATE_DIR: /home/node/.openclaw
      OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
      OPENCLAW_CONFIG_DIR: /home/node/.openclaw
      OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace

      OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN:-}
      OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}

      GH_TOKEN: ${GH_TOKEN:-}
      GITHUB_TOKEN: ${GITHUB_TOKEN:-}
      COPILOT_GITHUB_TOKEN: ${COPILOT_GITHUB_TOKEN:-}

      BROWSER: echo
      TZ: ${OPENCLAW_TZ:-Asia/Seoul}
    volumes:
      - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
      - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
    cap_drop:
      - NET_RAW
      - NET_ADMIN
    security_opt:
      - no-new-privileges:true
    stdin_open: true
    tty: true
    init: true
    entrypoint: ["node", "dist/index.js"]
    depends_on:
      - openclaw-gateway

여기서 중요한 점은 두 가지다.

첫째, .env의 OPENCLAW_CONFIG_DIR와 OPENCLAW_WORKSPACE_DIR는 NAS host 경로다.

OPENCLAW_CONFIG_DIR=/volume1/docker/openclaw/.openclaw
OPENCLAW_WORKSPACE_DIR=/volume1/docker/openclaw/.openclaw/workspace

둘째, compose의 environment 안에 있는 OPENCLAW_CONFIG_DIR와 OPENCLAW_WORKSPACE_DIR는 컨테이너 내부 경로다.

OPENCLAW_CONFIG_DIR: /home/node/.openclaw
OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace

둘을 혼동하면 컨테이너 안에서 host 경로를 만들려고 하거나, permission error가 발생할 수 있다.

bridge 모드로 운영하는 이유

이 compose 파일은 Docker bridge 모드를 기준으로 한다.

ports:
  - "127.0.0.1:18789:18789"

이 설정은 OpenClaw gateway를 NAS의 127.0.0.1:18789에만 노출한다.

즉, 같은 내부망에 있는 다른 기기에서 직접 http://NAS_IP:18789로 접근할 수 없고, SSH 터널을 통해서만 접근한다.

이 방식의 장점은 다음과 같다.

  • gateway를 외부 네트워크에 직접 노출하지 않음
  • 기존 SSH 터널 접속 방식과 잘 맞음
  • Synology 방화벽과 별개로 노출 범위를 줄일 수 있음

왜 gateway bind는 lan인가

처음에는 gateway를 loopback으로 두는 것이 더 안전해 보인다.

하지만 Docker bridge 모드에서는 컨테이너 내부의 loopback은 NAS host의 loopback이 아니라 컨테이너 자기 자신의 loopback이다.

따라서 host의 published port를 통해 컨테이너 gateway에 접근하려면, 컨테이너 내부 OpenClaw는 loopback이 아니라 lan으로 bind하는 것이 안전하다.

compose에서는 다음처럼 설정했다.

command:
  [
    "node",
    "dist/index.js",
    "gateway",
    "--bind",
    "lan",
    "--port",
    "18789"
  ]

host 쪽 포트는 여전히 127.0.0.1:18789에만 열어두기 때문에 외부 노출 범위는 제한된다.

커스텀 이미지 빌드

Compose 프로젝트 폴더로 이동한다.

cd /volume1/docker/openclaw

커스텀 이미지를 빌드한다.

sudo docker compose build

캐시 없이 다시 빌드하려면 다음 명령을 사용한다.

sudo docker compose build --no-cache

OpenClaw gateway 실행

gateway를 실행한다.

sudo docker compose up -d openclaw-gateway

빌드와 실행을 한 번에 하려면 다음 명령을 사용한다.

sudo docker compose up -d --build openclaw-gateway

상태 확인:

sudo docker compose ps

로그 확인:

sudo docker compose logs -f openclaw-gateway

health check

NAS host에서 gateway health check를 확인한다.

curl -fsS http://127.0.0.1:18789/healthz
curl -fsS http://127.0.0.1:18789/readyz

정상이라면 HTTP 응답이 반환된다.

SSH 터널로 대시보드 접속

이 구성에서는 gateway가 NAS의 127.0.0.1:18789에만 노출되어 있으므로, 외부 기기에서는 SSH 터널을 통해 접속한다.

ssh -L 18789:127.0.0.1:18789 agent@<NAS_IP>

그 다음 브라우저에서 접속한다.

http://127.0.0.1:18789

이 방식은 브라우저의 localhost secure context 조건을 만족시키기 쉽고, OpenClaw gateway를 내부망에 직접 노출하지 않아도 된다.

OpenClaw CLI 실행 방식

Docker Compose 구조에서는 host에 openclaw CLI를 직접 설치하지 않는다. 대신 openclaw-cli 서비스를 통해 실행한다.

버전 확인:

sudo docker compose run -T --rm openclaw-cli --version

상태 확인:

sudo docker compose run -T --rm openclaw-cli status --json

doctor:

sudo docker compose run -T --rm openclaw-cli doctor

모델 상태 확인:

sudo docker compose run -T --rm openclaw-cli models status --json

cron 목록 확인:

sudo docker compose run -T --rm openclaw-cli cron list

-T 옵션을 언제 쓰고 언제 빼야 하나

Docker Compose에서 -T는 pseudo-TTY 할당을 끄는 옵션이다.

Synology 터미널에서 리치 출력이 불안정하거나, JSON 출력만 확인할 때는 -T를 붙이는 편이 안정적이다.

예:

sudo docker compose run -T --rm openclaw-cli status --json
sudo docker compose run -T --rm openclaw-cli doctor

하지만 로그인, wizard, device auth처럼 interactive TTY가 필요한 명령에는 -T를 붙이면 안 된다.

잘못된 예:

sudo docker compose run -T --rm openclaw-cli models auth login --provider github-copilot

이 경우 다음 에러가 날 수 있다.

models auth login requires an interactive TTY

올바른 예:

sudo docker compose run --rm openclaw-cli models auth login-github-copilot

정리하면 다음과 같다.

상태 확인, doctor, JSON 출력 → -T 사용
로그인, wizard, device auth → -T 제거

컨테이너 내부 shell 접속

CLI 컨테이너 shell:

sudo docker compose run --rm --entrypoint sh openclaw-cli

실행 중인 gateway 컨테이너 shell:

sudo docker compose exec openclaw-gateway sh

명령만 실행:

sudo docker compose run -T --rm --entrypoint sh openclaw-cli -lc 'pwd && ls -al'

설치한 도구 확인:

sudo docker compose run -T --rm --entrypoint sh openclaw-cli -lc 'gh --version && rg --version && jq --version'

GitHub CLI 토큰 설정

컨테이너 안에서 gh를 사용하려면 GitHub token을 .env에 넣는다.

GH_TOKEN=github_pat_xxxxxxxxxxxxxxxxx
GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxx

.env 수정 후 gateway를 재시작한다.

sudo docker compose restart openclaw-gateway

컨테이너 안에서 gh 인증 상태 확인:

sudo docker compose run -T --rm --entrypoint gh openclaw-cli auth status

repo 접근 확인:

sudo docker compose run -T --rm --entrypoint gh openclaw-cli repo list

.env에는 민감정보가 들어가므로 공개 저장소에 올리면 안 된다.

자주 쓰는 명령어

프로젝트 폴더 이동

cd /volume1/docker/openclaw

이미지 빌드

sudo docker compose build

캐시 없이 빌드

sudo docker compose build --no-cache

gateway 실행

sudo docker compose up -d openclaw-gateway

빌드 포함 실행

sudo docker compose up -d --build openclaw-gateway

gateway 로그 확인

sudo docker compose logs -f openclaw-gateway

gateway 재시작

sudo docker compose restart openclaw-gateway

중지 및 컨테이너 제거

sudo docker compose down

상태 JSON 확인

sudo docker compose run -T --rm openclaw-cli status --json

doctor 실행

sudo docker compose run -T --rm openclaw-cli doctor

shell 접속

sudo docker compose run --rm --entrypoint sh openclaw-cli

설치 도구 확인

sudo docker compose run -T --rm --entrypoint sh openclaw-cli -lc 'gh --version && rg --version && jq --version'

업데이트

sudo docker compose pull
sudo docker compose build --no-cache
sudo docker compose down
sudo docker compose up -d openclaw-gateway

자주 만난 에러와 해결

1. no configuration file provided: not found

no configuration file provided: not found

원인:

docker-compose.yml이 없는 디렉터리에서 docker compose 실행

해결:

cd /volume1/docker/openclaw
sudo docker compose run -T --rm openclaw-cli doctor

또는 compose 파일 경로를 직접 지정한다.

sudo docker compose 
  -f /volume1/docker/openclaw/docker-compose.yml 
  --env-file /volume1/docker/openclaw/.env 
  run -T --rm openclaw-cli doctor

2. .openclaw EACCES

EACCES: permission denied, open '/home/node/.openclaw/logs/config-health.json'

원인:

컨테이너 내부 node 사용자(uid 1000)가 host bind mount 폴더에 쓰기 불가

해결:

sudo chown -R 1000:1000 /volume1/docker/openclaw/.openclaw

3. Docker socket permission denied

permission denied while trying to connect to the Docker daemon socket

원인:

agent 계정이 /var/run/docker.sock 접근 권한 없음

해결:

sudo docker compose ...

Synology에서는 Docker socket 권한을 넓히는 것보다 sudo 또는 DSM Container Manager UI를 쓰는 편이 안전하다.

4. models auth login requires an interactive TTY

models auth login requires an interactive TTY

원인:

interactive login 명령에 -T 옵션을 붙임

해결:

sudo docker compose run --rm openclaw-cli models auth login-github-copilot

상태 확인이나 JSON 출력에는 -T를 쓰고, 로그인류 명령에는 -T를 빼면 된다.

DSM Container Manager에서 관리하기

SSH에서 Compose를 실행해도 되지만, DSM UI에서는 Container Manager의 Project로 관리할 수 있다.

흐름:

  1. /volume1/docker/openclaw 폴더 생성
  2. Dockerfile, .env, docker-compose.yml 작성
  3. DSM Container Manager → Project → Create
  4. 경로를 /volume1/docker/openclaw로 지정
  5. compose 내용을 확인하고 생성

이렇게 하면 DSM UI에서도 컨테이너 상태, 로그, 재시작을 확인할 수 있다.

마무리

Synology NAS에 OpenClaw를 Docker Compose로 신규 설치하면 host 환경에 직접 의존하지 않고 안정적으로 gateway와 CLI를 운영할 수 있다.

이번 구성에서 가장 중요한 포인트는 다음 네 가지다.

  1. .openclaw는 host에 보존하고 컨테이너에는 /home/node/.openclaw로 mount한다.
  2. OpenClaw 컨테이너는 uid 1000의 node 사용자로 실행되므로 host 폴더 권한을 맞춘다.
  3. bridge 모드에서는 gateway bind를 lan으로 두고, host에는 127.0.0.1:18789만 노출한다.
  4. CLI 명령은 openclaw-cli 컨테이너로 실행하고, 비대화형 명령에는 -T를 붙인다.

이 구조를 잡아두면 OpenClaw gateway 실행, CLI 상태 확인, Telegram 채널 설정, 모델 인증, cron 테스트까지 모두 Docker Compose 안에서 일관되게 관리할 수 있다.

Your Story: Local Gemma 4 Interactive Fiction That Remembers Your Choices

Your Story screenshots

This is a submission for the Gemma 4 Challenge: Build with Gemma 4

What I Built

Your Story is a Flutter mobile app for interactive fiction powered by local Gemma 4 models.

The app starts from structured story templates, then lets players act in natural language. Instead of branching only through fixed choices, the player can type actions like “I hide the beans and tell Mother the truth” or “I ask the giant’s wife for help.” The story engine evaluates what is possible, Gemma 4 writes the next passage, and deterministic state logic records permanent consequences.

The goal is to make AI storytelling feel less like a loose chatbot and more like a playable novel:

  • formal beat structure keeps pacing coherent
  • entity state tracks relationships, inventory, locations, and decisions
  • Gemma 4 writes contextual prose and dialogue
  • autosave preserves each turn
  • local inference keeps core play available without depending on a server

The first story is Jack and the Beanstalk, with 13 beats, 6 characters, 7 locations, and a state model for choices such as trading the cow, taking treasure, earning trust, or angering the giant.

Demo

Video walkthrough: https://github.com/tuandinh0801/your-story/blob/main/docs/assets/your-story-mobile-demo.mp4

Your Story screenshots

Planned walkthrough:

  • open the story library
  • start Jack and the Beanstalk
  • choose local Gemma 4 mode
  • play several natural-language turns
  • show character and journal state updating from choices
  • show router/model status for local and cloud agent routes

Code

Repository: https://github.com/tuandinh0801/your-story/tree/main

How I Used Gemma 4

Gemma 4 powers the core storytelling loop. It is not just used for a side feature.

I use two local Gemma 4 LiteRT-LM models through flutter_gemma:

Model Use Reason
Gemma 4 E2B IT fast local turns good fit for latency-sensitive narration, action interpretation, and summaries
Gemma 4 E4B IT quality local turns better fit for richer prose and reasoning-heavy story moments

The app also has a Firebase AI cloud route for fallback and enhanced tasks. Each agent request declares a routing policy:

  • local fast for latency-sensitive story turns
  • local quality for richer narration
  • cloud quality for complex branching or when local is unavailable
  • cloud image route for visual generation work

The router records which backend and model served each request. That makes the multi-agent system visible for debugging while keeping the reader experience simple.

Your Story agent architecture

Why Gemma 4 fits this project

Interactive fiction needs low-latency language generation, but it also needs long-running coherence. A pure cloud chatbot can produce prose, but it can drift from prior choices. A fixed branching engine can preserve state, but it limits the player’s imagination.

Your Story splits the job:

  • Gemma 4 handles prose, dialogue, interpretation, and summaries.
  • Dart engine code handles state, preconditions, effects, beat progression, persistence, and validation.
  • Context packing keeps the current world, beat, memories, and action inside the model budget.

This lets Gemma 4 do the creative language work while the app keeps the world consistent.

What makes it original

The app treats a story like a living state machine, not a chat transcript. The AI receives story rules and state context, then the engine validates what changed. Choices become durable facts that later turns can reference.

For example, if Jack trades Daisy for beans, that inventory change and relationship impact are stored as state. Later narration can reflect Mother’s disappointment or trust because those values are no longer just text in an earlier paragraph.

Technical highlights

  • Flutter mobile app with Riverpod.
  • Local Gemma 4 E2B/E4B through flutter_gemma.
  • Firebase AI fallback route.
  • Drift for relational story state.
  • Hive for long prose and summaries.
  • Pure Dart engine for beat progression and effect validation.
  • Template-driven story format.
  • Mobile design system built around a calm storybook reading experience.

How I Built a Language That Beats C on LZ77 by 6.6 — Bootstrapped from Assembly

Disclosure: This project was built with AI assistance (Claude).

I built a systems programming language called Jda from scratch — zero C, zero Rust,
zero Python anywhere in the toolchain. The compiler is bootstrapped from raw x86-64
assembly, fully self-hosted, and compiles itself to a byte-identical binary.

## The Headline Result

On LZ77 compression (1 MB of data), Jda clocks 277ms against:

  • C (clang -O2): 1,830ms — Jda is 6.6× faster
  • Rust (rustc -O): 2,185ms — Jda is 7.9× faster
  • Go: 2,721ms — Jda is 9.8× faster

Jda is running via Rosetta 2 x86-64 on Apple Silicon — not even native ARM64.

## Full Benchmark Table (Apple Silicon, ms, lower is better)

| Benchmark | C | Rust | Go | Jda |
|—|–:|–:|–:|–:|
| Sudoku — 500 puzzles | 62 | 62 | 66 | 41 |
| LZ77 — 1 MB compress | 1,830 | 2,185 | 2,721 | 277 |
| Regex — 8 pats × 100K | 98 | 221 | 813 | 186 |
| B-Tree — 1M ops | 282 | 297 | 318 | 586 |
| Raytracer — 800×600 | 19 | 21 | 35 | 331 |

Jda wins 3 of 5 benchmarks against native ARM64 compiled languages.

## Why Is LZ77 6.6× Faster Than C?

Two compiler optimizations do the work:

1. MOD→AND strength reduction

The LZ77 hash-chain uses a 4096-entry window. Every iteration computes:
hash % 4096
Jda’s compiler rewrites this to:
hash & 4095
This eliminates every IDIV instruction from the inner loop. C, Rust, and Go
all keep the slower division form on this pattern.

2. Loop-Invariant Code Motion (LICM)

The maximum match length and first-byte filter are loop-invariant — Jda hoists
them out of the inner match scan, halving the number of iterations.

## Sudoku: 1.5× Faster Than C and Rust

The hot path is a bitmask scan over candidates. Jda’s MOD→AND peephole,
copy propagation, and loop register promotion eliminate redundant work that
gcc/clang keeps in memory.

## Self-Hosting

The compiler bootstraps like this:

Assembly → jda0 → compiles jda1.jda → jda1
↓
compiles jda1.jda → jda1_sh2
↓
compiles jda1.jda → jda1_sh3
↑
byte-identical to sh2

Fixed point converged. 388 conformance tests passing.

## Compile Speed

| | gcc -O2 | rustc -O | go build | Jda |
|–|–:|–:|–:|–:|
| Average | 479ms | 1,497ms | 712ms | 43ms |

33× faster than Rust. Single-pass compiler, no linker, no intermediate files.

## What It Has

  • No GC — manual memory with alloc_pages
  • Goroutine-style green threads + channels
  • Tensors, autograd, neural networks
  • AVX-512 / CUDA / ROCm acceleration
  • 117 stdlib packages
  • Full-stack web framework (Jda Forge)
  • VS Code + JetBrains plugins

## Try It


bash
  curl -sSf https://jdalang.org/install.sh | sh

  - GitHub: https://github.com/jdalang/jda-lang
  - Website: https://jdalang.org
  - Benchmarks: https://jdalang.org/benchmarks/complex-benchmarks/

  All benchmark source code is in the repo — six implementations of each problem
  (C, Rust, Go, Jda, Ruby, Python) side by side.

  Would love feedback on the language design, benchmark methodology, or anything
  that feels wrong or missing.

Google I/O 2026 – From “Prompting” to “Acting”

Google I/O 2026 felt different.
Not because the demos were flashier.
Not because the models were bigger.
And not because AI-generated video got absurdly realistic.
This year, Google stopped treating AI as a chatbot layer.
Instead, it introduced something much more ambitious:
AI as an operating system for action.
The moment that convinced me wasn’t even a single product launch. It was the connective tissue between multiple announcements:
Gemini 3.5 Flash
Gemini Spark
Antigravity 2.0
AI-powered Search agents
Android Halo
Workspace Live features
Together, they point toward the same future:
We are moving from “AI that answers questions” to “AI that continuously works beside you.”
And I think that changes software development more than most people realize.
The Announcement That Stood Out: Gemini Spark + Agentic Infrastructure
The release that stayed in my head after the keynote was Gemini Spark.
Google described it as a persistent AI agent layer capable of taking actions across apps, workflows, documents, search, and devices.
At first glance, it sounds like another AI assistant announcement.
It isn’t.
The important detail is that Google quietly connected:
multimodal reasoning,
long-context memory,
tool use,
background task execution,
and cross-product integration
into one ecosystem.
That’s the real story of I/O 2026.
Gemini 3.5 Flash Might Be More Important Than Gemini 3.5 Pro
Ironically, the most impactful model announcement may not be the flagship model at all.
Google delayed Gemini 3.5 Pro until next month, which disappointed a lot of attendees.
But the more interesting release was Gemini 3.5 Flash.
Why?
Because Google optimized it for:
speed,
agentic workflows,
coding,
multimodal execution,
and continuous interaction.
This matters because agents don’t behave like chatbots.
A chatbot can tolerate latency.
An active AI system cannot.
If an AI agent is:
monitoring your workflows,
modifying files,
coordinating subtasks,
generating UI,
executing tool chains,
or responding in real time,
then responsiveness becomes infrastructure.
That’s why Gemini 3.5 Flash feels strategically important:
it’s engineered less like a conversational model and more like a runtime engine for AI systems.
Antigravity 2.0 Quietly Signals the Future of Software Development
The most underrated developer announcement at I/O 2026 was probably Google Antigravity 2.0.
Most coverage focused on Gemini.
But Antigravity reveals Google’s actual long-term direction:
developers orchestrating teams of AI agents instead of writing every step manually.
Some of the features announced include:
managed agents,
asynchronous task execution,
subagents,
workspace permissions,
background cron workflows,
and native Android app generation from prompts.
That combination changes the role of developers.
The future developer workflow increasingly looks like:
describe intent,
supervise execution,
refine outputs,
compose systems.
Not:
manually implement every primitive from scratch.
This doesn’t eliminate engineering.
It elevates architecture, orchestration, and systems thinking.
The Real Surprise: Google Finally Connected Everything
Previous AI conferences often felt fragmented:
one model here,
one assistant there,
one experimental demo somewhere else.
I/O 2026 felt more unified.
Google connected:
Search,
Android,
Workspace,
YouTube,
AI Studio,
XR,
Shopping,
and developer tooling
around a single agentic layer.
That coherence matters.
Because the strongest AI ecosystems won’t necessarily win through benchmark scores.
They’ll win through integration density.
And Google has an advantage very few companies can match:
Search, Android, Chrome, Gmail, Docs, Maps, YouTube, and Cloud already form a gigantic behavioral operating system.
Now Gemini is becoming the reasoning layer across all of it.
My Favorite Demo Wasn’t the Flashiest One
A lot of people focused on Gemini Omni creating and editing video from multimodal inputs.
And yes — the demos were impressive.
But the moment that actually stuck with me was Google reframing Search itself.
The new AI Search experience can:
monitor webpages,
manage information streams,
maintain persistent context,
and coordinate agents over time.
That’s not traditional search anymore.
That’s closer to:
“continuous computational attention.”
Instead of searching repeatedly, users increasingly delegate awareness itself.
That’s a massive UX shift.
The Critique: Google Risks Turning Everything Into “AI Everywhere”
Not every announcement landed perfectly.
One concern I had throughout the keynote:
Google is aggressively inserting AI into nearly every product surface simultaneously.
Some of it feels transformative.
Some of it feels unnecessary.
The danger is interface overload.
If every product becomes:
conversational,
proactive,
agentic,
predictive,
interrupt-driven,
then cognitive noise becomes the new UX problem.
The companies that win the next phase of AI won’t just build the smartest systems.
They’ll build the calmest ones.
What Developers Should Actually Pay Attention To
If you’re a developer, I think these are the most important signals from I/O 2026:

  1. Agents are becoming first-class software primitives
    Not just chat features.
  2. Speed now matters as much as intelligence
    Latency determines usability for continuous AI systems.
  3. Multimodal is becoming infrastructure
    Text-only interaction is no longer the center.
  4. AI orchestration is replacing isolated prompts
    The future is systems of cooperating models and tools.
  5. The interface layer is changing
    Search boxes, IDEs, browsers, and operating systems are all evolving into agent surfaces.
    Final Thought
    Google I/O 2026 convinced me that the AI race is no longer primarily about who has the smartest model.
    It’s about who builds the most usable intelligence ecosystem.
    And for the first time in a while, Google looked less like a company shipping isolated AI features…
    …and more like a company building an AI-native computing platform.
    That’s a much bigger shift than another benchmark chart.

Most enterprise operations are powered by interruption

A lot of companies think workflows run through software.

They don’t.

They run through:
‘Did you check this?
‘Can you approve this quickly?’
‘Reminder for pending task’
‘Following up again’

Calls.
Pings.
Messages.
Escalations.

Remove interruptions for one day and many operations slow down immediately.

That usually means the process is not actually self sustaining.

People are acting as the execution engine between disconnected systems, unclear ownership, and delayed decisions.

The software tracks the work.

Interruptions move the work.

This is something we see often at BrainPack. Many organizations look automated on the surface, but operational movement still depends heavily on human follow ups and manual coordination.