DEV Community

Ahmad Karawash
Ahmad Karawash

Posted on

Amoxi: AI agents that survive restarts and talk to other agents

Durable runs that pick up where they stopped, side effects that never happen twice, and your agents offered to Claude, ChatGPT and other agents over MCP and A2A.

In article 5 every run took about a second. Real agent work is slower: several model calls, a payment provider, an email server. Somewhere in those minutes a deploy restarts the server, or a process crashes. What happens to the run? And did the customer get refunded once, twice, or not at all?

This article continues with the support assistant from article 5. We add a refund tool, kill the server in the middle of a refund, and then open the agent to other AI clients.

Runs are background jobs

When a message arrives, Amoxi does not run the agent inside the HTTP request. It saves a run and returns at once; a background worker executes it, and the browser follows along through the event stream. After every model turn and every tool result, the run is checkpointed to storage (files under data/ai/ by default).

That checkpoint is what makes a run durable. If the process dies, another worker, or the same server after a restart, picks the run up from its last checkpoint.

A tool with real side effects

A refund has two side effects: money moves, and the customer gets an email. Create src/orders/payments.ts, which stands in for a payment provider and writes every refund to a file so we can count them:

import { appendFileSync, mkdirSync } from 'node:fs';
import { Singleton } from 'amoxi';

/** Stands in for a payment provider: every refund is appended to data/refunds.log. */
@Singleton()
export class Payments {
  refund(orderId: string, idempotencyKey: string): { refundId: string } {
    mkdirSync('data', { recursive: true });
    const refundId = `R-${Date.now()}`;
    appendFileSync('data/refunds.log', `${refundId} ${orderId} key=${idempotencyKey}\n`);
    return { refundId };
  }
}
Enter fullscreen mode Exit fullscreen mode

And the tool, src/orders/refund-tools.ts:

import { Singleton } from 'amoxi';
import { Tool, type ToolContext } from 'amoxi/ai';
import { Payments } from './payments';

@Singleton()
export class RefundTools {
  constructor(private readonly payments: Payments) {}

  /** Refund an order and email the customer a receipt. */
  @Tool({ idempotent: true })
  async refundOrder(
    input: {
      /**
       * The order number, e.g. A-1001
       * @pattern ^A-\d{4}$
       */
      orderId: string;
    },
    ctx: ToolContext,
  ): Promise<{ refundId: string; emailed: boolean }> {
    const { refundId } = await ctx.step('refund', () => this.payments.refund(input.orderId, ctx.callId));
    ctx.progress('Refund done, sending the receipt...');
    await ctx.step('email', async () => {
      await new Promise((r) => setTimeout(r, 4000)); // a slow mail server
      console.log(`receipt for ${refundId} sent`);
      return true;
    });
    return { refundId, emailed: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

Add RefundTools to the agent's tools list. Three things in this tool matter:

  • ctx.step('refund', fn) runs fn and records its result in the run. If the tool runs again after a crash, a completed step returns the recorded result instead of running again.
  • @Tool({ idempotent: true }) tells Amoxi the whole tool is safe to run again, because its side effects are inside steps.
  • ctx.callId is passed to the payment provider as an idempotency key. It stays the same when an interrupted call is retried, and is different for every new call. Most payment APIs accept such a key, and it protects you even if the crash happens inside the provider's call.

ctx.progress(...) shows a status line in the chat while the slow part runs.

Pull the plug

Start the app, ask "Refund order A-1001", and while the receipt is "sending", kill the process hard (Ctrl+C is too polite; close the terminal, or use Task Manager or kill -9). The refund has already been recorded:

R-1792425180411 A-1001 key=offline_1
Enter fullscreen mode Exit fullscreen mode

Start the app again. When the dead worker's claim on the run expires (30 seconds by default, ai.runs.lease-ms), the run resumes. The tool runs again: the refund step returns its recorded result, so no second refund is made, and the email step, which never finished, runs now:

receipt for R-1792425180411 sent
Enter fullscreen mode Exit fullscreen mode

data/refunds.log still has exactly one line, and the run completes with the original refund id. The person watching the chat sees it continue as if nothing happened; the browser reconnects to the event stream by itself.

What if you do not mark a tool idempotent? Then Amoxi never runs an interrupted call again blindly. The model is told the action may or may not have completed, and it can check (for example by looking the order up) or ask the person. That is the safe default for tools you have not made idempotent.

Cancelling

POST /agents/support-agent/runs/<id>/cancel stops a run, even in the middle of a tool. Tools receive ctx.signal, a standard AbortSignal; pass it to fetch and other calls that accept one so they stop too.

When one server is not enough

The file storage is right for one server. For several, switch to PostgreSQL with pgvector:

npm install pg
Enter fullscreen mode Exit fullscreen mode

Set "ai": { "store": { "type": "postgres" } } in application.json and provide DATABASE_URL. Runs then become a shared queue: any server can claim a waiting run, and servers started with ai.runs.worker=false only serve web traffic. The code of your agent does not change.

Your agent as an MCP server

The Model Context Protocol (MCP) lets AI clients such as Claude, ChatGPT, Cursor and IDE assistants call tools on a server. Every Amoxi app can be one. Generate a token for each client that will connect. This command works in bash and in PowerShell:

node -e 'console.log(require(`crypto`).randomBytes(32).toString(`hex`))'
Enter fullscreen mode Exit fullscreen mode

Enable MCP in application.json:

{ "ai": { "mcp": { "enabled": true } } }
Enter fullscreen mode Exit fullscreen mode

Then give each caller its own token through an environment variable, AI_MCP_CALLERS_<NAME>, for example AI_MCP_CALLERS_DESKTOP for a desktop client. Keep tokens out of application.json and out of git. Amoxi refuses to start with a token shorter than 16 characters.

Any MCP client that supports remote servers can now connect to http://localhost:8080/mcp with the header Authorization: Bearer <token>. Without the token, the answer is a 401. With it, tools/list returns:

list_notes          readOnlyHint
add_note
lookup_order        readOnlyHint
refund_order        idempotentHint
ask_assistant
ask_support_agent
Enter fullscreen mode Exit fullscreen mode

Notice what is there and what is not:

  • Your tools are offered with hints taken from your decorators: readOnly becomes readOnlyHint, idempotent becomes idempotentHint.
  • cancel_order is missing. Tools that need approval are never offered over MCP, because no person is there to approve.
  • Each agent appears as an ask_<agent> tool, so a client can delegate a whole question to it.
  • refund_order is offered directly. That is probably not what you want for a tool that moves money. Hide it with @Tool({ idempotent: true, expose: false }); the agent can still use it.

Calling lookup_order returns the order as text and as structuredContent, typed from your return type.

And if a client asks the agent to do something that needs approval?

ask_support_agent  { "message": "Cancel order A-1002" }
-> "That didn't work: The person declined this action."
Enter fullscreen mode Exit fullscreen mode

The agent was told why and answered accordingly. If your MCP callers are signed-in people of your app, set ai.mcp.approvals to wait, and the run pauses until that person approves it in the app.

Agent to agent with A2A

The A2A protocol is for agents talking to agents: a partner's purchasing agent asking your support agent about an order, for example. Enable it the same way:

{ "ai": { "a2a": { "enabled": true } } }
Enter fullscreen mode Exit fullscreen mode

Give each partner a token with AI_A2A_CALLERS_<NAME>. Other agents discover yours at /.well-known/agent-card.json, which describes the app as one A2A agent with one skill per Amoxi agent:

{ "name": "agent-app", "url": "http://localhost:8080/a2a", "skills": ["assistant", "support-agent"] }
Enter fullscreen mode Exit fullscreen mode

A partner sends a task to a skill:

{ "jsonrpc": "2.0", "id": 1, "method": "message/send",
  "params": {
    "message": { "role": "user", "messageId": "m1", "parts": [{ "kind": "text", "text": "Where is order A-1001?" }] },
    "metadata": { "skill": "support-agent" } } }
Enter fullscreen mode Exit fullscreen mode

And gets a completed task back, with the answer as an artifact:

Here's what I found:
ID: A-1001
Item: Trail backpack, 28 L
Status: shipped
Enter fullscreen mode Exit fullscreen mode

Runs map to A2A tasks, conversations to contexts, and approvals to the input-required state.

Callers cannot see each other

Because every caller has its own token, Amoxi knows who is asking. A caller that sends its token owns its conversations, as a2a:partner or mcp:desktop. It can list and continue only its own tasks, and whatever the agent remembers about one caller stays with that caller. Your tools can tell channels apart too: runs started over MCP or A2A carry the principal { channel: 'mcp' } or { channel: 'a2a' }.

MCP and A2A are both off by default, and your own middleware runs before them, so you can add IP rules or rate limits like for any other route.

Recap

  • Runs are background jobs, checkpointed after every step, and resume after a crash or restart.
  • ctx.step makes side effects happen once; ctx.callId is a ready-made idempotency key.
  • Tools that are not idempotent are never re-run blindly.
  • One setting turns your app into an MCP server and an A2A agent, with per-caller tokens and isolated conversations.
  • Tools that need approval are never offered to outside clients, and expose: false hides others.

Next

The last article takes the app to production: amoxi start on every core, Docker, secrets and configuration, health checks, and the checklist to go through before the first real user arrives.

Amoxi is open source under the Apache License 2.0. Code: github.com/karawash-projects/amoxi. Documentation: karawash-projects.github.io/amoxi.

Top comments (0)