Building an outbound-only WebSocket bridge for local AI agents

Building an outbound-only WebSocket bridge for local AI agents

Building an outbound-only WebSocket bridge for local AI agents
I work with AI agents every day. Claude Code, Copilot, Gemini CLI — running locally, with access to my filesystem, my repos, my tools. The results are genuinely good. But there’s a wall: the moment you leave your desk, you lose control. There’s no real way to kick off an agent task from your phone, monitor a long-running pipeline from a coffee shop, or schedule something to run overnight.

Every solution I found had the same trade-off: you either open a port, install a tunnel daemon, or upload your code to someone’s cloud. None of those felt right for infrastructure that has access to your local filesystem.

So I built CTRL NODE — a browser-based control plane for local AI agents. The key piece is a process called the Bridge: a lightweight Node.js daemon that runs on your machine and connects to the cloud without ever accepting an inbound connection.

This article is about how that works, why the design choices matter, and what the actual code looks like.

Why outbound-only?

The naive approach is to expose your local agent runtime on a port and let the cloud reach in. Tools like ngrok do exactly this — they create a reverse proxy to your localhost. It works, but it has real costs:

  • Open port = attack surface. Every ngrok tunnel is a publicly reachable endpoint. If auth breaks, someone else can talk to your agent.
  • Third-party traffic relay. Your prompts, file paths, and agent responses travel through ngrok’s infrastructure.
  • Daemon complexity. You’re running persistent infrastructure that you didn’t write and can’t audit easily.

The alternative: flip the connection direction. The Bridge connects out to the cloud. The cloud pushes commands down that connection. The local machine never listens on a public port.

Your machine                     ctrlnode.ai cloud
──────────────────────────────────────────────────
Bridge ──── ws:// connect() ────▶ WebSocket server
       ◀─── {action: "run_task", ...} ────────────
       ─────  stdout/stderr events ──────────────▶

This is the same pattern used by IoT devices, CI agents (like the GitHub Actions runner), and remote desktop clients. The cloud doesn’t initiate — it waits.

The connection lifecycle

Here’s the core of websocket.ts:

export function connect(): void {
  const url = buildWsUrl();
  ws = new WebSocket(url, { headers: buildAuthHeaders() });

  ws.on("open", () => {
    logger.info("Bridge connected to SAAS");
    flushPendingQueue();
    startHeartbeat();
  });

  ws.on("message", (data: WebSocket.RawData) => {
    const message = JSON.parse(data.toString()) as InboundMessage;
    handleInboundMessage(message);
  });

  ws.on("close", (code: number, reason: Buffer) => {
    stopHeartbeat();
    if (isAuthError(code, reason.toString())) {
      logger.warn(`Auth error (${code}), retrying in ${AUTH_RETRY_MS / 1000}s`);
      setTimeout(connect, AUTH_RETRY_MS);
    } else {
      scheduleReconnect();
    }
  });

  ws.on("error", (err: Error) => {
    logger.error(`WebSocket error: ${err.message}`);
  });
}

Three things to notice:

  1. Auth errors get a longer timeout. If the server returns 1008 (Policy Violation) or 1002, or the reason string contains "401"/"403"/"Unauthorized", we wait 30 seconds before retrying. Hammering an auth-rejected endpoint is pointless and noisy.

  2. Normal closes trigger exponential backoff. scheduleReconnect() uses a standard backoff so a transient network blip doesn’t flood logs.

  3. On open, we flush the queue. More on this below.

Keeping the connection alive through load balancers

Cloud load balancers will kill idle WebSocket connections after 30–60 seconds. The fix is a heartbeat:

const HEARTBEAT_INTERVAL_MS = 20_000;
let heartbeatTimer: NodeJS.Timeout | null = null;

function startHeartbeat(): void {
  heartbeatTimer = setInterval(() => {
    sendToSaas({ type: "heartbeat", timestamp: Date.now() });
  }, HEARTBEAT_INTERVAL_MS);
}

function stopHeartbeat(): void {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer);
    heartbeatTimer = null;
  }
}

Every 20 seconds, a small message goes up. The server acknowledges it (or doesn’t — we don’t care, the goal is just to keep TCP active). This is cheap and it works reliably with AWS ALB, Cloudflare, and most managed WebSocket proxies.

Buffering outbound messages during disconnection

When the Bridge is reconnecting, agent output still arrives. If we drop those events, the user watching a pipeline in their browser sees a gap in the live log. The solution is a small in-memory queue:

const PENDING_QUEUE_MAX = 100;
const pendingQueue: OutboundMessage[] = [];

export function sendToSaas(message: OutboundMessage): void {
  if (!ws || ws.readyState !== WebSocket.OPEN) {
    if (pendingQueue.length < PENDING_QUEUE_MAX) {
      pendingQueue.push(message);
    }
    return;
  }
  ws.send(JSON.stringify(message));
}

function flushPendingQueue(): void {
  while (pendingQueue.length > 0) {
    const msg = pendingQueue.shift()!;
    ws!.send(JSON.stringify(msg));
  }
}

Cap at 100 messages, flush on reconnect. Simple, and it handles the common case of a 2–3 second reconnect window without losing events.

Multi-agent routing via the filesystem

Here’s the part that took the most thought: how do you run multiple agents — Claude, Copilot, Gemini — on the same machine, routing tasks to the right one?

The answer isn’t a routing layer in the WebSocket code. It’s the filesystem.

Each pipeline task gets an isolated directory:

workspace/
  tasks/
    task-abc123/
      input/
        TASK.md          ← instructions for the agent
        context-files/   ← any files the user attached
      output/
        TASK.md          ← agent writes progress here
        artifacts/       ← anything the agent produces

The Bridge watches these directories. When a run_task command arrives:

case "run_task": {
  const { taskId, agentProvider, workspacePath } = message.payload;
  const provider = getProvider(agentProvider); // Claude | Copilot | Gemini | ...
  await provider.executeTask(taskId, workspacePath);
  break;
}

Each provider implementation knows how to invoke its agent CLI with the right arguments and working directory. Claude Code gets claude --print with the task directory. Copilot gets its own invocation. They never share context — each runs in its own subprocess, reading from and writing to its own task folder.

This means:

  • No prompt pollution. Agent A’s context doesn’t leak into Agent B.
  • Parallel execution. Two agents can run simultaneously without coordination overhead.
  • Auditability. Every task leaves a paper trail on disk.
  • Portability. The cloud control plane never sees your file contents. It only sees task metadata and status events.

Provider selection and gating

Some actions only make sense for certain providers. The message handler maintains an explicit set:

const OPENCLAW_ONLY_ACTIONS = new Set([
  "openclaw_configure",
  "openclaw_stream_chunk",
  "openclaw_reset_context",
]);

function handleInboundMessage(message: InboundMessage): void {
  if (OPENCLAW_ONLY_ACTIONS.has(message.action) && activeProvider !== "openclaw") {
    logger.warn(`Received ${message.action} but provider is ${activeProvider} — ignoring`);
    return;
  }
  // ... dispatch to handler
}

This prevents misconfigured cloud deployments from accidentally sending the wrong command type to the wrong agent. The Bridge is the last line of defense before your filesystem.

The startup sequence

index.ts ties it together:

async function main(): Promise<void> {
  const providers = await createProviders(config);
  const multi = new MultiProvider(providers);

  connect(); // start WebSocket, non-blocking

  const keepaliveInterval = setInterval(() => {}, 1 << 30);
  keepaliveInterval.unref(); // don't prevent process exit

  process.on("SIGINT", gracefulShutdown);
  process.on("SIGTERM", gracefulShutdown);

  await multi.runSyncAgents(); // provider-specific background sync
}

The keepaliveInterval trick (unref()) is worth noting: it keeps the event loop alive when nothing else is pending, but doesn’t prevent a clean SIGINT/SIGTERM from shutting the process down. Without it, connect() is async and Node exits immediately after starting.

What this enables

With the Bridge running, the CTRL NODE web app can:

  • Launch tasks against any connected agent from any browser, anywhere
  • Watch live output streamed back over the same WebSocket
  • Schedule routines — the cloud scheduler wakes the Bridge at the configured time, no cron job needed on the local machine
  • Run multi-step pipelines where each node can use a different agent

None of your code leaves your machine. The cloud only sees: “task started”, “task output line”, “task completed”. The actual file contents, prompts, and agent context stay local.

Why open source?

The Bridge is MIT licensed (github.com/ctrlnode-ai/ctrlnode). You can read every line of the WebSocket handler, every message type, every auth check. If you don’t trust the binary, build it yourself.

The rest of CTRL NODE — the cloud scheduler, the web app, the real-time pipeline view — runs as a hosted service. The Bridge is the trust boundary: it’s the piece that runs with access to your local system, and it needs to be auditable.

Try it

If you work with AI agents and want a way to control them remotely without sacrificing privacy:

  1. Install the Bridge: npm install -g @ctrlnode/bridge && ctrlnode bridge start
  2. Sign up at ctrlnode.ai — it’s free
  3. Open the web app from anywhere and connect

Questions, issues, or PRs: github.com/ctrlnode-ai/ctrlnode or reply here.

Javier Vil — Creator of CTRL NODE

Our System’s Sins in Ghana: Why We Had to Rethink Digital Product Sales

The Problem We Were Actually Solving

At first glance, our platform was simply a successful e-commerce solution. But the truth was more complex. We had designed it to bypass local payment gateways, because they were either unreliable or outright forbidden in many countries, including Ghana. Instead, we routed payments through international gateways, which provided us with the scale and speed we needed. However, this made our platform inaccessible to a significant portion of our user base in Ghana. We were trying to solve the problem of scaling payments without considering the needs of the users in countries we were trying to include.

What We Tried First (And Why It Failed)

Initially, we tried to fix this issue by developing a separate plugin for Ghanaian payment gateways, but it was a failure. The plugin was complex to implement, and support was spotty at best. Users in Ghana often encountered a barrage of errors, from “card declined” to “payment failed.” It was clear we needed a different approach. We thought localizing payment options was the answer, but what we didn’t realize was that this would only address the symptoms of a deeper problem, not the root cause.

The Architecture Decision

After weeks of research, we finally made a breakthrough. We decided to integrate our platform with local payment providers directly, removing the need for international gateways altogether. This would not only resolve the issue but also improve our overall performance, as transactions would be processed locally and in real-time. We realized that our system’s design had been driven by the constraints of international payments rather than the needs of our users. By shifting our focus to local providers, we could tap into a more extensive network of payment options and improve our overall user experience.

What The Numbers Said After

After deploying the new architecture, we saw a significant uptick in sales from Ghana. The average transaction time dropped by over 70%, and the overall success rate for payments increased by 25%. These numbers told us that we had made the right decision. Our users in Ghana could now easily purchase digital products using their preferred payment methods. This change not only improved our user experience but also opened up new revenue streams for creators.

What I Would Do Differently

In retrospect, we should have considered the needs of our international users from the outset. We were so focused on scaling payments that we overlooked the consequences of our design choices on users in countries like Ghana. If I were to do it again, I would prioritize user experience and inclusivity much earlier in the development process. This would have saved us months of tinkering with workarounds and potentially prevented the disappointment of our users in Ghana.

Execution Governance, AI Drift, and the Security Paradox of Runtime Enforcement

Governance Paradox

Author: Michal Harcej | 23 May 2026

The next major battle in AI may not be model capability.

It may be execution governance.

As autonomous systems evolve beyond passive assistants into operational agents capable of making decisions, interacting with infrastructure, and executing actions in real environments, a deeper problem emerges:

How do we govern probabilistic intelligence under operational consequence?

Most current AI safety approaches remain largely:

  • policy-level,
  • observational,
  • post-hoc,
  • or moderation-oriented.

But increasingly, new architectures are attempting to move governance closer to execution itself.

This is where concepts such as:

  • runtime mediation,
  • hardware-anchored verification,
  • deterministic constraint enforcement,
  • semantic drift detection,
  • and execution assurance layers

begin entering the discussion.

The idea is simple in principle:

Instead of merely asking an AI system to behave safely, the system’s execution pathways themselves become governed.

In practical terms:

AI proposes action
↓
Governance layer validates admissibility
↓
Execution allowed, denied, quarantined, or escalated

This represents a shift from:
“trusting model behavior”

toward:

“verifying executable admissibility.”

The architectural direction is extremely important.

But it also introduces a serious paradox.

The deeper governance moves toward:

  • kernel layers,
  • hypervisors,
  • runtime mediation,
  • trusted execution,
  • hardware-rooted attestation,

the more privileged the governance layer itself becomes.

And historically, privileged infrastructure becomes the primary attack target.

Governance Paradox

Security engineering repeatedly demonstrates this pattern:

  • antivirus platforms became exploit surfaces,
  • hypervisors faced escape attacks,
  • identity providers became centralized compromise points,
  • firmware trust systems introduced new persistence vectors.

Execution governance systems may face the same challenge.

A runtime enforcement layer capable of:

  • validating execution,
  • constraining autonomy,
  • mediating actions,
  • or anchoring operational truth

also creates:

  • additional attack surface,
  • semantic manipulation opportunities,
  • synchronization vulnerabilities,
  • trust concentration,
  • and systemic dependency risk.

This becomes especially critical in systems relying on:

  • deterministic timing,
  • semantic validation,
  • distributed coordination,
  • or hardware-level trust assumptions.

Even more interesting is the rise of semantic governance itself.

Future systems may not merely validate permissions.
They may validate operational meaning.

This introduces entirely new categories of risk:

  • semantic drift,
  • governance erosion,
  • policy reinterpretation,
  • entropy escalation,
  • and adversarial admissibility manipulation.

At that point, governance is no longer simply cybersecurity.

It becomes:

  • operational systems theory,
  • bounded autonomy engineering,
  • admissibility architecture,
  • and execution consequence management.

This is why the future of governed intelligence may ultimately depend less on adding infinite monitoring layers and more on reducing operational entropy itself.

The deeper architectural question becomes:

Can intelligence systems be designed with fundamentally bounded admissible state spaces before runtime complexity becomes ungovernable?

That question may define the next era of AI infrastructure.

tauguard.ai

Small AI database questions can become big scans

A small question can become a big database scan when an AI agent writes the query.

“Show me customers at risk” sounds harmless.

But depending on schema context, the agent might join:

  • accounts
  • subscriptions
  • invoices
  • usage events
  • support tickets
  • notes
  • activity logs

Then it may retry when the first query does not answer the question.

For production MCP database servers, row limits should be treated as a safety boundary, not a UI preference.

Useful defaults:

  • preview first, not full export
  • aggregate before raw rows when possible
  • enforced row limits per tool/intent
  • page cursors for continuation
  • explicit “more rows exist” metadata
  • structured refusal for unbounded requests
  • audit logs with rows scanned and rows returned

Longer version: Row limits for AI database agents

The model should never summarize 50 preview rows as if it saw the whole database.

Your AI database agent should not see every column

The fastest way to leak sensitive data through an AI database agent is to expose columns the model never needed.

Table access is too broad.

A customer table can contain useful business fields and risky fields at the same time:

  • account name
  • plan
  • renewal date
  • usage trend
  • email
  • phone
  • private notes
  • raw payloads
  • billing references
  • internal flags

The agent may need the first four. It probably does not need the rest.

For production MCP database access, I would rather expose approved projections than raw tables:

  • approved views for common business questions
  • masked/pseudonymized fields by default
  • aggregate-only modes for trend analysis
  • structured refusal for blocked columns
  • audit logs showing exactly which columns were returned

Longer version: Column-level permissions for AI database agents

The model should not be the thing deciding whether a sensitive field is safe to see.

Rust’s Low-Latency Conquest: Why We Ditched C++ for a Treasure Hunt Engine

The Problem We Were Actually Solving

In our case, the problem wasn’t just about serving a high volume of users; it was about ensuring that our system could handle the sudden spikes in traffic that come with the Treasure Hunt format. Our database-driven configuration layer was struggling to keep pace, and the C++ implementation was making it difficult to scale cleanly.

What We Tried First (And Why It Failed)

Our initial approach involved manually managing memory allocation, which was supposed to improve performance. However, this led to a plethora of issues, including:

  • Memory leaks due to incorrect pointer management
  • Memory reallocations causing significant slow-downs
  • Increasing latency as the system struggled to cope with incoming requests

Using tools like gprof and Valgrind, we were able to pinpoint areas of the code that were causing the slow-downs, but the resulting patchwork of fixes made it difficult to maintain and extend the codebase.

The Architecture Decision

It was then that we decided to transition to Rust, a language that promises low-latency performance through its focus on memory safety. We knew it wouldn’t be an easy decision – Rust has a steep learning curve, and our team was accustomed to writing C++ code. However, we were convinced that the benefits of memory safety and thread-safety would outweigh the costs in the long run.

What The Numbers Said After

After migrating the database-driven configuration layer to Rust, we witnessed a significant improvement in performance:

  • Average latency dropped by 30%
  • The number of requests served per second increased by 50%
  • Allocation counts reduced by 70%

These numbers spoke volumes about the effectiveness of our decision to transition to Rust. While there were initial growing pains as our team adapted to the new language, the payoff was well worth it.

What I Would Do Differently

Looking back, I would have been more aggressive in promoting Rust as a viable alternative from the start. While the transition was challenging, I believe that our choice to migrate was the correct one. However, I would have taken a more tiered approach to the rollout, ensuring that our developers had more time to adapt to the new language.

The decision to migrate to Rust was not taken lightly, but it was a crucial one. By abandoning our C++ implementation and adopting a more memory-safe approach, we were able to build a system that can handle the demands of a Treasure Hunt format without stalling at the first growth inflection point. While there are still areas for improvement, I firmly believe that Rust has been a game-changer for our engineering team, and that its benefits will only continue to grow as we push the limits of what is possible.

Floating-point will quietly corrupt your emissions math, and 0.1 + 0.2 already warned you

Every developer has seen this:

0.1 + 0.2
// 0.30000000000000004

It’s the most-shared bug in programming. You learn it, you nod, you move on — because in most code the error is so far down the decimal that nothing notices. A pixel is off by a billionth. A timer fires a nanosecond late. Nobody files a ticket.

Then you build a system whose entire job is to add thousands of numbers together and produce a total that has to match a figure someone calculated by hand. And the rounding error you waved away in the tutorial becomes a line in an audit report that says: the system total does not reconcile to the source data.

That’s where I met this bug for real, building the calculation layer at GreenCalculus.com. It runs over a thousand emissions calculators, and every one of them has the same hard requirement: the number the engine produces must reconcile, to the last reported digit, against the number a practitioner gets when they check the math themselves. “Close enough” is a finding. This post is the set of rules that get you from the math looks right to the math reconciles.

Why this bites harder in measurement code than anywhere else

Most software treats numbers as means to an end — coordinates, counters, ratios feeding a render. The exact value rarely is the deliverable.

In emissions accounting, the number is the deliverable. A reported figure of 1,240.37 tCO2e is the product. If an auditor recomputes it and gets 1,240.41, the 0.04 isn’t noise — it’s an unexplained discrepancy, and unexplained discrepancies are exactly what an audit exists to surface. You don’t get to say “floating-point.” You get to fix your data model.

Three properties of this domain turn a harmless rounding error into a real defect:

  • Massive summation. A Scope 3 inventory adds tens of thousands of line items. Error accumulates with count.
  • Wide dynamic range. You add a 4,000,000 kg figure to a 0.002 kg figure in the same total. This is precisely the case floating-point handles worst.
  • External reconciliation. The output is checked against an independent calculation. There’s no hiding internal drift, because someone outside your system recomputes it.

The float problem, stated precisely

A 64-bit IEEE 754 float (double, JavaScript’s only number type) stores 52 bits of mantissa — about 15–17 significant decimal digits. 0.1, 0.2, and 0.3 are not representable exactly in binary, the same way 1/3 isn’t representable exactly in decimal. They’re stored as the nearest available binary fraction, and the tiny errors survive arithmetic.

(0.1 + 0.2).toFixed(20)
// "0.30000000000000004441"

For a single operation the error is ~10⁻¹⁷. The problem is what happens when you do the operation 50,000 times, and when the operands differ in magnitude by a factor of a billion.

Failure mode 1: the total that won’t reconcile

Naive summation of a long list of factors:

function totalEmissions(lineItems) {
  let total = 0;
  for (const item of lineItems) {
    total += item.activity * item.factor;   // each term carries float error
  }
  return total;
}

Each activity * item.factor lands on the nearest representable double, slightly off. Each += rounds again. Over tens of thousands of items the errors are mostly random and partly cancel — but “mostly” is not “exactly,” and the residual is deterministic for a given input order. Run the same data through a spreadsheet that sums in a different order and you get a different last digit. Now you have two “correct” totals that disagree, and no way to say which is right.

Fix: control precision at the boundary, not in the loop

Two defensible strategies, depending on stack.

Decimal arithmetic for the money-and-mass path. Store and sum factors as arbitrary-precision decimals so there’s no binary-representation error at all:

import Decimal from 'decimal.js';

function totalEmissions(lineItems) {
  return lineItems
    .reduce((acc, item) =>
      acc.plus(new Decimal(item.activity).times(item.factor)),
      new Decimal(0))
    .toNumber();   // convert once, at the very end, after rounding regime applied
}

Decimal is slower. You don’t need it everywhere — you need it on the path whose output is a reported figure.

Kahan summation when you must stay in floats. If the hot path can’t afford Decimal, compensated summation recovers most of the lost low-order bits by tracking the rounding error and feeding it back in:

function kahanSum(values) {
  let sum = 0;
  let compensation = 0;        // running error term
  for (const v of values) {
    const y = v - compensation;
    const t = sum + y;
    compensation = (t - sum) - y;   // recovers what the addition just dropped
    sum = t;
  }
  return sum;
}

The single addition sum + y loses the low bits of y when sum is large; the next line reconstructs exactly those bits and carries them into the following iteration. For a long sum of mixed-magnitude terms this is the difference between a stable total and one that drifts with input order.

Failure mode 2: the wide-magnitude swallow

This is the one that produces a wrong number, not just an unstable one.

let total = 4_000_000.0;   // a large facility's annual figure, kg
total += 0.002;            // a trace fugitive line, kg
total === 4_000_000.002;   // true here — but stack enough trace lines...

When the accumulator is large and the addend is tiny, the addend’s significant bits fall off the bottom of the mantissa and are silently dropped. One trace line is fine. Ten thousand of them, each individually swallowed, is a missing category total. The sum looks authoritative — it’s a clean large number — which is exactly why nobody catches it by eye.

Fix: sum within magnitude bands, then combine

Add small things to small things before adding them to big things. Sort ascending and the smallest terms accumulate among themselves into a magnitude the large accumulator can actually absorb:

function bandedSum(values) {
  const ascending = [...values].sort((a, b) => a - b);
  return kahanSum(ascending);   // small-first ordering + compensation
}

Sorting ascending before a compensated sum is a cheap, robust default for “many small, few large.” It won’t matter on uniform data — but reference-data sums are rarely uniform.

Failure mode 3: the ratio that amplifies

Intensity metrics divide one computed figure by another — emissions per unit revenue, per tonne of product, per kWh. Division doesn’t create error, but it amplifies whatever error the operands already carry, and ratios of two nearly-equal slightly-wrong numbers are the worst case.

const intensityA = total2025 / output2025;
const intensityB = total2024 / output2024;
const yoyChange = (intensityA - intensityB) / intensityB;   // tiny - tiny, over tiny

When intensityA and intensityB are close, their difference is dominated by the rounding error in each, and dividing that noisy difference by a small base inflates it into a visible percentage. Your year-on-year intensity change can read as ±0.3% pure artefact.

Fix: defer rounding, widen the working precision

Carry full precision through the entire chain and round once, at presentation. Never round an intermediate that feeds a later division. If the figures are reconciliation-critical, do the division in Decimal too — the cost is trivial relative to one division per report.

Failure mode 4: the unit-boundary shave

Every conversion is a multiply, and every multiply is a place to lose digits. The g → kg → t chain looks innocent:

const grams = 1234567;
const kg = grams / 1000;        // 1234.567
const tonnes = kg / 1000;       // 1.234567
// round-trip back up and compare:
tonnes * 1000 * 1000 === grams; // not guaranteed

Each division lands on the nearest double; chaining them compounds the drift, and a value that round-trips through three units may not come back to where it started. The fix is the same architectural rule that governs the whole post: store in one canonical unit at full precision, convert only at the display boundary. Don’t persist the converted value and re-derive from it — persist the canonical value and convert on read.

The rule underneath all four

Every one of these is the same decision made wrong: where does precision get fixed, and how many times. The failures all come from rounding early, rounding repeatedly, or rounding in the middle of a chain that isn’t done yet.

The architecture that prevents all four:

  1. One canonical unit, full precision, in storage. No pre-rounded, pre-converted values persisted.
  2. A declared rounding regime, applied once, at the boundary. Half-up, half-even — pick one, document it, apply it where the number leaves the system, never before.
  3. Decimal on any path whose output is a reported figure. Floats are fine for charts and progress bars. They are not fine for the deliverable.
  4. Compensated, magnitude-aware summation wherever you must aggregate many terms in floating point.

This is the same shape as a rule we apply to every figure in the GreenCalculus data layer: a number is meaningless without its declared basis. There, the basis is the GWP version and the emission-factor source. Here, it’s the unit and the rounding regime. In both cases the discipline is identical — the number is not just its digits; it’s its digits plus the declared rules that produced them. Store the rules with the number, or you can’t defend the number.

0.1 + 0.2 was never the bug. It was the warning that your numbers carry rules you haven’t written down yet.

The GreenCalculus calculation layer applies these rules across 1,000+ emissions calculators, every one of which has to reconcile against its source data to the last reported digit. Methodology documentation at greencalculus.com.

Autonomous Agents: what breaks first (and why that’s the real product) [2026-05-23]

Most “AI agents” you see online are basically:

  • a scheduled loop
  • a prompt
  • a couple API calls

That is not autonomy. That’s automation with a nice demo.

What happened in my own pipeline (real failures)

In the last day, I saw three recurring breakpoints:

1) Rate limits / quotas (HTTP 429)
Your agent can be correct and still fail because the business side (quota) says no.

2) Model availability drift (HTTP 401 / unsupported model)
Config says one thing. Provider reality says another. If you don’t validate, your jobs silently die.

3) Permissions and scopes (write-only social tokens)
You can post, but you cannot read the feed to engage unless your app has the right scopes. Viral growth isn’t just content. It’s feedback loops.

My updated definition of autonomy

An agent is autonomous only if it can:

  • Detect failure modes
  • Degrade gracefully (fallback model, backoff, queue, alternate provider)
  • Keep shipping outputs even when part of the pipeline is down

The unsexy part (tokens, scopes, quotas, retries, idempotency) is the product.

Question for builders

What breaks first for you in production?

  • rate limits
  • auth/scopes
  • state/data

Created by Ramagiri Tharun

— tarun

Agent payments are the new cloud bill footgun

shut up and take my money

AWS has been previewing more AgentCore pieces, and one small phrase should make platform teams sit up a little straighter: managed payment capabilities for agents.

Not just agents calling tools.

Not just agents reading docs.

Agents paying for APIs, MCP servers, web content, and other agents as part of a workflow.

That sounds like a product feature. It is also a very clean way to create the next cloud bill incident.

And to be fair, this was always coming. If agents are supposed to do useful work across company boundaries, they need access to paid things. APIs cost money. Data providers cost money. Specialized tools cost money. Other agents may cost money. The internet is not a charity with JSON.

But the moment an agent can spend money, cost control stops being a monthly finance exercise and becomes a runtime safety problem.

That is the part I think many teams are underestimating.

shut up and take my money

token cost was the warm-up round

For the last couple of years, the obvious AI cost story has been tokens.

How many prompts are we sending? Which model are we using? Are we routing simple work to cheaper models? Are we caching? Is the invoice weird because one team built a summarizer that reads the same 800-page document every morning?

Those are real questions.

But they are still mostly contained inside the model layer. You can meter them. You can route them. You can put budgets around them. You can yell at the dashboard and eventually find the service that became expensive.

Agent payments are different because the spending can happen outside the model call.

The agent does not only spend tokens thinking about the task. It can spend money while executing the task.

It might buy access to a document, call a paid enrichment API, trigger a paid SaaS workflow, ask another specialized agent for analysis, then try again because the first attempt failed in a slightly different way.

The scary part is not that one call costs money.

The scary part is that the loop costs money.

autonomous commercial action is a new permission

We already understand permissions like read, write, deploy, delete, assume role, access secret, approve release.

“Spend” needs to sit in that family.

Not as an afterthought. Not as a finance tag someone checks next quarter. As a first-class capability.

Because spending is not only accounting. It is action. If an agent buys access to a dataset, calls a paid API, or pays another service to complete part of a task, it has changed the state of the business. Maybe only by a few cents. Maybe by a few thousand dollars. The scale is an implementation detail.

In a normal human workflow, we rely on friction. The person sees a checkout screen. They know the card is corporate. They hesitate before approving a weird vendor. They ask someone in Slack. They decide if the task is worth the cost.

Agents remove friction by design.

That is the whole selling point.

So the control model cannot depend on the same friction.

If an agent can spend, the platform needs to know which identity is paying, who authorized it, what budget applies, which vendors are allowed, which tasks can spend, and what happens when spending exceeds the expected range.

This is not a procurement policy problem. This is a runtime control-plane problem.

“just set a budget” is not enough

Budgets are useful. They are also blunt.

Most cloud teams have learned this the boring way. A monthly AWS budget alert that fires at 80% is nice, but it does not stop the bad loop that started on Saturday night. It tells you the room is warm after the fire has already made plans.

Agent spending has the same shape, only faster and less legible.

Imagine an agent debugging a production issue. It calls a paid log-analysis API, then a paid dependency graph service, then a premium trace from an observability vendor, then a remediation agent to generate a rollback plan. Each step is defensible in isolation.

The total workflow is what matters.

That means controls need to operate at the workflow level, not only the vendor or account level.

I would want rules like: this incident agent can spend up to X per incident, this research agent can buy documents only from approved sources, this code migration agent cannot pay external services when handling private repositories, and this workflow stops if retry spending grows faster than successful progress.

That last one is important. Agents are very good at making failure look busy. A stuck workflow can be expensive because the agent will keep trying plausible next actions until some guardrail says no.

the invoice needs a call graph

A normal cloud bill tells you what service cost money.

For agent payments, that will not be enough.

The useful question is not only “which API charged us?”

It is: which agent started the workflow, what was the human request, what did the agent believe it was buying, what approval or policy allowed it, did the paid result get used, and did the workflow succeed?

This is where cost observability and agent observability merge.

If the bill says an agent spent 700 USD on third-party API calls, finance will ask why. Engineering should not answer with a shrug and a trace full of tool calls. The answer needs to connect spend to intent, policy, and outcome.

Otherwise nobody can tell the difference between useful automation and a very polite money leak.

Cloud cost tools already struggle with this. Tagging is incomplete. Shared infrastructure blurs ownership. Kubernetes makes spend more dynamic. AI workloads add model routing, token volume, GPU time, vector storage, and agent calls.

Payments add another layer: commercial side effects created by autonomous workflows.

That deserves better than a tag called agent=true.

there is a fintech smell here

Maybe this is my fintech bias showing, but I think teams should treat agent payments more like ledger events than like API calls.

An API call is ephemeral. A ledger event is durable, explainable, and auditable.

If an agent spends money, there should be a record with enough structure to answer basic questions later: who, what, why, amount, vendor, workflow, approval, policy, result.

Because money movement without durable semantics becomes pain very quickly. Finance will ask. Security will ask. Legal may ask. The customer may ask if the paid action used their data. The incident review may ask why an agent kept buying premium analysis after the deployment had already been rolled back.

If the answer is buried in logs, you do not have governance. You have archaeology.

what i would build before turning this on

If I were enabling paid agent actions in a company, I would start with a boring rule: no invisible spending.

Every payment-capable agent needs a named budget, a named owner, and a named business purpose. Tiny actions can happen automatically. Moderate actions require task-level justification. Expensive actions require human approval. Unusual vendors need explicit allowlisting. Repeated failed attempts trigger a stop.

I would also force agents to attach payments to workflows, not just identities. “The research agent spent 40 USD” is less useful than “the market research workflow for customer X spent 40 USD on approved data source Y.”

Finally, I would make unused paid results visible. If an agent buys something and does not use it, that is a signal. Maybe the workflow is wasteful. Maybe the tool is unreliable. Maybe the agent is looping.

the punchline

Agents that can pay for things will be useful. This is not a rant against the feature.

Actually, the feature is probably necessary. Serious agent workflows will need commercial access to serious tools. Free-tier demos are not how production systems get built.

But spending is a capability. Once agents can spend, cost control, identity, policy, auditability, and workflow design become the same conversation.

The old cloud bill footgun was leaving a big instance running.

The new one is an agent making a thousand reasonable paid decisions inside a loop nobody modeled.

That is not a finance problem at the end of the month.

That is production behavior.

So treat it like production behavior. Give it identity. Give it policy. Give it limits. Give it logs that explain intent, not just activity.

And please, before the agent gets a wallet, make sure someone owns the receipt.

references

ORA-00069 오류 원인과 해결 방법 완벽 가이드

ORA-00069란?

ORA-00069 에러는 “cannot acquire lock – table locks disabled”, 즉 테이블 잠금(Table Lock, 일명 TM Lock)이 비활성화된 상태에서 해당 테이블에 잠금을 획득하려 할 때 발생하는 에러입니다. Oracle은 DML(INSERT, UPDATE, DELETE) 및 DDL 작업 시 테이블 수준의 잠금을 사용하는데, 특정 테이블에 대해 DISABLE TABLE LOCK 옵션이 설정되어 있으면 이 잠금 획득 자체가 불가능해집니다. 주로 고가용성 환경이나 Oracle Parallel Server(OPS) / RAC 환경에서 성능 최적화를 위해 의도적으로 테이블 잠금을 비활성화한 경우, 또는 실수로 비활성화된 경우에 이 에러가 발생합니다.

주요 발생 원인

1. 의도적 또는 실수로 인한 DISABLE TABLE LOCK 설정

가장 흔한 원인으로, DBA 또는 개발자가 ALTER TABLE ... DISABLE TABLE LOCK 명령을 실행하여 해당 테이블의 TM 잠금을 비활성화한 경우입니다. RAC 환경에서 인터커넥트 트래픽을 줄이기 위한 성능 튜닝 목적으로 이 설정을 적용하기도 하지만, 이후 일반 DML 작업 시 ORA-00069가 발생하게 됩니다. 운영 중 다른 DBA나 스크립트에 의해 실수로 적용된 경우도 많아, 장애 발생 시 반드시 이 설정 여부를 우선 확인해야 합니다.

2. DDL 작업(ALTER TABLE, TRUNCATE 등) 수행 시 잠금 충돌

TRUNCATE TABLE, ALTER TABLE ... ADD COLUMN 등 DDL 작업은 내부적으로 테이블 수준의 배타적 잠금(Exclusive TM Lock)을 요구합니다. 테이블 잠금이 비활성화된 상태에서 이러한 DDL을 실행하면 ORA-00069가 발생합니다. 특히 배치 작업 스크립트나 마이그레이션 스크립트에서 TRUNCATE 후 INSERT 패턴을 사용할 때 빈번히 마주치는 상황입니다.

3. Oracle Parallel Server(OPS) 또는 RAC 환경의 잘못된 구성

Oracle RAC(Real Application Clusters) 환경에서는 노드 간 글로벌 잠금 관리(Global Enqueue Service, GES)를 통해 테이블 잠금을 처리합니다. 일부 오래된 RAC 설정 가이드에서는 성능을 위해 특정 테이블의 잠금을 비활성화하도록 권고한 경우가 있으며, 이러한 설정이 남아있는 상태에서 신규 애플리케이션이 해당 테이블에 접근하면 에러가 발생합니다. 인프라 이전이나 RAC 노드 증설 후 기존 설정이 그대로 유지되어 문제가 불거지는 경우도 실무에서 자주 확인됩니다.

해결 방법

원인 1 해결: 테이블 잠금 비활성화 여부 확인 및 재활성화

먼저 현재 테이블의 잠금 상태를 확인합니다. DBA_TABLES 또는 USER_TABLES 뷰의 ROW_MOVEMENT 컬럼과 함께 TABLE_LOCK 관련 정보를 조회할 수 있습니다.

-- 테이블 잠금 상태 확인 (DBA 권한 필요)
SELECT owner,
       table_name,
       row_movement,
       -- TABLE_LOCK 비활성화 여부는 직접 컬럼이 없으므로 아래 방법으로 확인
       status
FROM   dba_tables
WHERE  table_name = 'YOUR_TABLE_NAME'
AND    owner = 'YOUR_SCHEMA';

-- v$locked_object와 함께 현재 잠금 현황 확인
SELECT lo.oracle_username,
       lo.os_user_name,
       lo.session_id,
       lo.locked_mode,
       do.object_name,
       do.object_type
FROM   v$locked_object lo
JOIN   dba_objects do ON lo.object_id = do.object_id
WHERE  do.object_name = 'YOUR_TABLE_NAME';

테이블 잠금 비활성화 여부를 확인하는 가장 확실한 방법은 실제로 잠금을 시도해 보거나, DBMS_METADATA로 DDL을 추출하는 것입니다.

-- 테이블 DDL 추출을 통해 DISABLE TABLE LOCK 여부 확인
SELECT dbms_metadata.get_ddl('TABLE', 'YOUR_TABLE_NAME', 'YOUR_SCHEMA')
FROM   dual;

테이블 잠금이 비활성화되어 있음을 확인했다면, 아래 명령으로 즉시 재활성화합니다.

-- 테이블 잠금 재활성화 (핵심 해결 명령)
ALTER TABLE your_schema.your_table_name ENABLE TABLE LOCK;

-- 재활성화 후 정상 DML 작업 가능 여부 테스트
LOCK TABLE your_schema.your_table_name IN ROW SHARE MODE;
-- 정상이라면 에러 없이 잠금 획득됨
COMMIT;

원인 2 해결: DDL 수행 전 잠금 상태 확인 절차 추가

배치 스크립트나 마이그레이션 스크립트에서 TRUNCATE 전에 잠금 상태를 확인하고, 비활성화된 경우 자동으로 활성화하는 로직을 추가합니다.

-- 잠금 비활성화 상태에서 TRUNCATE 시도 시 ORA-00069 발생 예시
-- (아래는 에러 재현 및 해결 흐름)

-- Step 1: 테이블 잠금 비활성화 (문제 상황 재현)
ALTER TABLE hr.employees DISABLE TABLE LOCK;

-- Step 2: 이 상태에서 TRUNCATE 시도 → ORA-00069 발생
-- TRUNCATE TABLE hr.employees;  ← 에러 발생!

-- Step 3: 잠금 재활성화 후 TRUNCATE 정상 수행
ALTER TABLE hr.employees ENABLE TABLE LOCK;
TRUNCATE TABLE hr.employees;

-- 배치 프로시저에서 활용 가능한 안전한 패턴
CREATE OR REPLACE PROCEDURE safe_truncate(
    p_owner      IN VARCHAR2,
    p_table_name IN VARCHAR2
) AS
    v_sql VARCHAR2(500);
BEGIN
    -- 1. 테이블 잠금 활성화 보장
    v_sql := 'ALTER TABLE ' || p_owner || '.' || p_table_name || ' ENABLE TABLE LOCK';
    EXECUTE IMMEDIATE v_sql;

    -- 2. 안전하게 TRUNCATE 수행
    v_sql := 'TRUNCATE TABLE ' || p_owner || '.' || p_table_name;
    EXECUTE IMMEDIATE v_sql;

    DBMS_OUTPUT.PUT_LINE(p_owner || '.' || p_table_name || ' truncated successfully.');
EXCEPTION
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE('Error: ' || SQLERRM);
        RAISE;
END safe_truncate;
/

-- 프로시저 실행
EXEC safe_truncate('HR', 'EMPLOYEES');

원인 3 해결: RAC 환경에서 잘못 비활성화된 테이블 일괄 점검 및 복구

RAC 환경에서 여러 테이블에 걸쳐 잠금이 비활성화된 경우, 아래 스크립트로 일괄 확인 및 활성화 스크립트를 생성합니다.

-- DISABLE TABLE LOCK 상태인 테이블 목록 및 활성화 스크립트 생성
-- (실제로 TABLE LOCK 비활성화는 딕셔너리에 특정 플래그로 저장됨)
-- 아래는 테스트 환경에서 검증된 확인 방법

-- sys.obj$ 기반 플래그 확인 (고급 방법, SYS 권한 필요)
SELECT o.name        AS table_name,
       u.name        AS owner,
       o.flags,
       -- flags 비트 32 = TABLE LOCK DISABLED
       CASE WHEN BITAND(o.flags, 32) = 32
            THEN 'TABLE LOCK DISABLED'
            ELSE 'TABLE LOCK ENABLED'
       END AS lock_status,
       -- 활성화 스크립트 자동 생성
       CASE WHEN BITAND(o.flags, 32) = 32
            THEN 'ALTER TABLE ' || u.name || '.' || o.name || ' ENABLE TABLE LOCK;'
            ELSE NULL
       END AS fix_script
FROM   sys.obj$ o
JOIN   sys.user$ u ON o.owner# = u.user#
WHERE  o.type# = 2   -- TABLE
AND    BITAND(o.flags, 32) = 32  -- TABLE LOCK DISABLED 조건
ORDER BY u.name, o.name;

-- 생성된 스크립트를 실행하여 일괄 복구
-- 위 쿼리 결과의 fix_script 컬럼 값을 복사하여 실행하거나,
-- 아래와 같이 동적으로 처리 가능
BEGIN
    FOR rec IN (
        SELECT 'ALTER TABLE ' || u.name || '.' || o.name || ' ENABLE TABLE LOCK' AS fix_sql
        FROM   sys.obj$ o
        JOIN   sys.user$ u ON o.owner# = u.user#
        WHERE  o.type# = 2
        AND    BITAND(o.flags, 32) = 32
        AND    u.name NOT IN ('SYS', 'SYSTEM', 'DBSNMP', 'SYSMAN')  -- 시스템 스키마 제외
    ) LOOP
        DBMS_OUTPUT.PUT_LINE('Executing: ' || rec.fix_sql);
        EXECUTE IMMEDIATE rec.fix_sql;
    END LOOP;
    DBMS_OUTPUT.PUT_LINE('All table locks re-enabled successfully.');
END;
/

예방 방법

1. 테이블 잠금 변경 작업에 대한 권한 통제 및 변경 관리 프로세스 수립

ALTER TABLE ... DISABLE TABLE LOCK 명령은 운영 환경에서 매우 신중하게 사용해야 하며, 가급적 DBA 전용 권한으로 제한해야 합니다. DDL 변경 이력을 추적하기 위해 DDL 트리거를 활용하여 테이블 잠금 변경 이벤트를 로깅하는 체계를 갖추면 장애 발생 시 원인 추적이 훨씬 수월해집니다.

-- DDL 변경 감사를 위한 로그 테이블 및 트리거 구성
CREATE TABLE dba_ddl_audit_log (
    log_id       NUMBER GENERATED ALWAYS AS IDENTITY,
    event_date   TIMESTAMP DEFAULT SYSTIMESTAMP,
    db_user      VARCHAR2(100),
    os_user      VARCHAR2(100),
    machine      VARCHAR2(100),
    object_type  VARCHAR2(50),
    object_name  VARCHAR2(128),
    ddl_text     CLOB,
    CONSTRAINT pk_ddl_audit PRIMARY KEY (log_id)
);

-- DDL 이벤트 트리거 (시스템 레벨 또는 스키마 레벨)
CREATE OR REPLACE TRIGGER trg_ddl_audit
AFTER DDL ON DATABASE
BEGIN
    -- TABLE LOCK 관련 ALTER TABLE 감지
    IF ora_dict_obj_type = 'TABLE' THEN
        INSERT INTO dba_ddl_audit_log (
            db_user, os_user, machine, object_type, object_name
        )
        SELECT 
            sys_context('USERENV', 'SESSION_USER'),
            sys_context('USERENV', 'OS_USER'),
            sys_context('USERENV', 'HOST'),
            ora_dict_obj_type,
            ora_dict_obj_name
        FROM dual;
        COMMIT;
    END IF;
END;
/

2. 정기적인 테이블 잠금 상태 점검 스크립트를 모니터링 체계에 편입

운영 환경에서 주기적(일별 또는 주별)으로 테이블 잠금 비활성화 여부를 점검하는 쿼리를 스케줄링하여, 비정상 상태가 감지되면 즉시 DBA에게 알림이 오도록 구성합니다. Oracle Enterprise Manager(OEM)의 커스텀 메트릭 기능이나 외부 모니터링 도구(Zabbix, Grafana 등)와 연동하면 효과적입니다.


sql
-- 주기적 점검용 스크립트 (cron 또는 DBMS_SCHEDULER로 실행)
-- TABLE LOCK이 비활성화된 테이블이 있으면 결과를 반환