Saturday, November 14, 2026

MERCURY AGENT - A FIELD GUIDE TO A SELF-HOSTED AGENTIC AI PLATFORM WRITTEN ENTIRELY IN TYPESCRIPT




WHAT MERCURY IS, AND WHY IT EXISTS


Mercury Agent (https://github.com/ms1963/mercury_agent) is a self-hosted, extensible agentic AI platform written entirely in TypeScript and designed to run on Node.js version 22.5 or newer.

The phrase "agentic platform" is doing a lot of work in that sentence, so it is worth unpacking before we go any deeper. An ordinary chatbot takes a message and returns a reply. An agent, by contrast, is given a goal and then reasons in a loop: it decides what to do, calls a tool, observes the result, revises its plan and repeats until the goal is met.

A platform is what surrounds that loop so the whole thing is usable by real people in production, which means a command line, a web interface, connectors to the chat applications people already use, persistent memory, security controls, and a way to observe and debug everything that happens. Mercury is all of that in one coherent codebase that you own and run yourself.

The single most important design fact about Mercury is that it has no built-in model. It is deliberately a runtime around a large language model rather than a model in a box. You point it at a provider you control or trust, and the same agent machinery works unchanged whether that provider is a local model served by Ollama or LM Studio on your own laptop, an OpenAI-compatible endpoint running inside your own network, or a remote cloud service such as Abacus.AI ChatLLM, OpenRouter, OpenAI or Anthropic.

This is the red thread that runs through the entire system and through this article, because it shapes the provider layer, the configuration model, the router and even the security posture. Everything else is built so that swapping the brain behind the agent is a one-line change.

Mercury also ships an optional and genuinely unusual component called the Jev decision engine. Where most agents fold every judgement into the same model prompt that writes the answer, Mercury separates the act of deciding from the act of answering. Jev is a typed decision layer that chooses which model should handle a task, judges how risky a proposed tool call is, triages incoming messages from chat platforms and decides when the agent should pause to reflect on what it has learned.

It is entirely optional; when it is not configured, Mercury falls back to a safe heuristic, so you are never forced to adopt it to get value from the rest of the platform. We will return to it, but it helps to know from the outset that "decide" and "answer" are treated as different responsibilities.

One more thing deserves to be stated plainly and early, because honesty about maturity is part of understanding any platform. Mercury carries a deterministic, offline test suite that exercises its subsystems against a mock provider, and that mock exists for exactly one reason, which is testing. In normal operation the mock is disabled and a real model is required.

The project is young, at version 0.1.0, and while the implemented surface area is large, it has not yet been hardened at scale against every real external chat platform and voice service. The accompanying status document in the repository is the authoritative record of what has and has not been exercised against live services, and you should read it before you make production commitments.

Throughout this article, when something is a design intention rather than a battle-tested guarantee, it is described as such.

THE SHAPE OF THE SYSTEM: LAYERS, PACKAGES AND EVENTS


Mercury is organised as an npm-workspaces monorepo, and the package boundaries are not cosmetic. They encode a strict dependency direction that keeps the domain logic pure and the input and output surfaces thin.

The core package, published internally as the at-mercury-core workspace, contains the configuration schema, the database access, authentication, the language-model abstraction, the decision engine, the agent loop, memory, skills, workflows, the scheduler and the security primitives. Crucially it contains no HTTP server; it depends only on two small libraries, the zod schema validator and a YAML parser, plus the Node built-ins.

Surrounding it are the tools package with the built-in and polyglot tool implementations, the mcp package that is both a client of other tool servers and a server exposing Mercury's own tools, the gateway package with its sixteen chat-platform adapters, and finally the runtime package whose only job is to build and wire everything together.

On top of the runtime sit the two front-ends that most users touch, namely the server package, which is a Fastify application serving the REST and streaming API, the webhooks, the MCP endpoint, a Prometheus metrics endpoint and the static web interface, and the cli package, which produces the mercury binary.

The dependency arrows only ever point inward: the command line and the server depend on the runtime, the runtime depends on tools, mcp and gateway, and all of them depend on core, while core depends on nothing of theirs. That single rule is what keeps the business logic testable in isolation and free of framework entanglement.

It is useful to picture the layering from top to bottom as a stack of responsibilities rather than a pile of files. The following figure sketches it.

FIGURE 1: The responsibility stack, top to bottom

FRONTENDS
  CLI / REPL    Web UI (SPA)    Chat Gateway
  MCP server    Scheduler

COMPOSITION
  createMercury(): builds and owns everything

ORCHESTRATION
  Agent loop + patterns    Workflow engine
  Subagents    Reflection

JUDGMENT
  Decision engine: Jev / LLM / safe heuristic

CAPABILITIES
  LLM providers + router    Tools (built-in, polyglot, MCP)    Memory    Skills    Voice

FOUNDATION
  Config (zod)    SQLite + FTS5    EventBus + Tracer + Metrics    Security    Auth + Budgets

The second pillar of the architecture, alongside the layering, is that everything is an event. Every language-model call, every tool invocation, every memory operation, every decision and every intermediate thought is published on an in-process event bus, persisted as a trace and made available as a live stream.

This is why Mercury can offer a replayable history of any run, a live debugging view with breakpoints, and Prometheus metrics, all without special-casing each feature. The observability is a consequence of the event-driven design rather than a bolt-on.

The third pillar is that content is untrusted by default. Anything that did not come directly from the authenticated human user, which includes tool output, fetched web pages, incoming emails, attachments and results from external MCP servers, is tagged as untrusted and visually spotlighted, and it can never by itself trigger a sensitive tool without passing through the approval and guardrail machinery.

This matters enormously for an agent that can read the open web and then act, because it is the structural defence against prompt-injection attacks, and it is woven into the type system rather than left to the discipline of individual tool authors.

THE PROVIDER ABSTRACTION: ONE INTERFACE, LOCAL AND REMOTE ALIKE


Because the choice of model is the red thread, the cleanest place to understand Mercury is the language-model abstraction, and the best news for anyone integrating it is how small that abstraction is. A provider is any object that can describe a model's capabilities, list the models it offers and perform a chat completion, optionally streaming the tokens as they arrive.

The interface is reproduced below exactly as the core package defines it, and its brevity is the point.

CODE: packages/core/src/llm/provider.ts

export interface LLMProvider {
  /\*\* Provider id used as the model prefix,
 e.g. "openai" in "openai/gpt-4o". */
  readonly id: string;
  /\*\* Wire type, e.g. "openai-compatible", "anthropic". \*/
  readonly type: string;
  capabilities(model: string): ModelCapabilities;
  listModels(): Promise\<ModelInfo[]\>;
  /\*\* Perform a chat completion. When onDelta is given
 the provider streams token deltas as they arrive. */
  chat(req: LLMRequest, onDelta?: DeltaHandler): Promise<LLMResponse>;
}

What makes local and remote models interchangeable is that the overwhelming majority of model servers in existence today speak the same dialect, namely the OpenAI chat-completions wire format. Ollama exposes it on a local port, LM Studio exposes it on another, llama.cpp and vLLM expose it, and the big cloud services expose it too.

Mercury therefore implements that dialect once, in a single OpenAI-compatible provider, and distinguishes one backend from another purely by base URL and an optional API key. The default base URLs it ships with show how little separates a model on your laptop from a model in a distant data centre.

CODE: packages/core/src/llm/providers/openai.ts

export const DEFAULT\_BASE\_URLS: Record\<string, string\> = {
  openai:     'https://api.openai.com/v1',
  openrouter: 'https://openrouter.ai/api/v1',
  lmstudio:   'http://localhost:1234/v1',   // local, no key
  ollama:     'http://localhost:11434/v1',  // local, no key
  chatllm:    'https://routellm.abacus.ai/v1',
};

Reading that table from top to bottom tells the whole story of the abstraction. The first two entries and the last one are remote services reached over the public internet and secured with a bearer token. The two middle entries point at localhost, need no credentials at all, and are served by a model running on your own machine.

The provider code does not branch on "is this local or remote"; it simply builds an Authorization header when a key is present and omits it when it is not. That is the entire difference, and it is why you can develop against a free local model and then move to a hosted one by changing a single configuration value.

The resilience of those calls is handled in one shared helper rather than scattered through each provider, which keeps the behaviour uniform and the error handling honest. The helper posts the request with a timeout, and it is careful to classify failures so the rest of the system can react intelligently.

A rate-limit response or a server error is marked retryable, whereas a bad request or an authentication failure is not, because retrying a wrong API key would only waste time and quota.

CODE: packages/core/src/llm/http.ts

export async function postJson(
  url, body, headers, signal, timeoutMs = 120\_000,
) {
  const timeout = AbortSignal.timeout(timeoutMs);
  const combined =
signal ? AbortSignal.any([signal, timeout]) : timeout;
  let res: Response;
  try {
res = await fetch(url, {
 method: 'POST',
 headers: { 'content-type': 'application/json', ...headers },
 body: JSON.stringify(body),
 signal: combined,
});
  } catch (err) {
if (signal?.aborted) {
 throw new ProviderError('aborted', undefined, false);
}
// Network-level failure (DNS, refused) is worth retrying.
throw new ProviderError(
 `network error: ${String(err)}`, undefined, true,
);
  }
  if (!res.ok) {
const text = await res.text().catch(() => '');
// 429 and 5xx are transient; everything else fails fast.
throw new ProviderError(
 text, res.status, res.status === 429 || res.status >= 500,
);
  }
  return res;
}

Once a provider exists, the registry is what turns a human-friendly model identifier into a concrete call. Model identifiers in Mercury always take the form of a provider prefix, a slash and then the backend model name, so that ollama/llama3.1 and chatllm/route-llm are unambiguous about which backend to use.

The registry splits that string at the first slash, looks up the provider and hands back both the provider object and the remaining model name. It also auto-enables any cloud provider whose API key it discovers in the environment, which is the mechanism behind the pleasant experience of setting one variable and immediately being able to select that provider's models.

HOW MERCURY IS USED


With a provider configured, the ways to actually use Mercury fan out from the same runtime. The command line is the most direct. A single chat subcommand sends a prompt and prints the answer, which is ideal for quick questions and for scripting.

The same binary starts the server that hosts the web interface and the API, manages scheduled tasks, runs workflows, administers users and tokens, inspects and installs tools and skills, operates the gateway to chat platforms, and runs the self-evaluation harness.

The full set of subcommands covers chat, config, doctor, eval, gateway, help, mcp, model, plugins, replay, schedule, serve, setup, skills, token, tools, update, user and workflow, and each is discoverable through the help subcommand and the web Help panel.

SHELL: everyday command-line usage

# one-shot question, prints the answer
mercury chat "Summarise today's entries in my project log"

# web UI + REST/SSE API
mercury serve --port 3000

# environment and provider health check
mercury doctor

# deterministic self-benchmark, machine-readable
mercury eval --json

The web interface is a no-build single-page application served directly by the server, which keeps deployment simple because there is no separate front-end build to manage. Through it a user chats with streaming responses, sees each tool call appear as a chip that can require explicit approval, attaches files, browses past sessions and runs, edits workflows, inspects memory and skills, manages tools and MCP connections, schedules tasks, configures integrations, reviews models and budgets, administers users, watches the live debug stream and reads the reflections the agent has written about its own work.

It offers light and dark themes and both English and German.

The agent loop that sits behind all of these surfaces runs, by default, in a streaming reason-and-act pattern: the model thinks, optionally calls a tool, observes the tagged result, and continues until it has an answer, emitting events at every step so the front-ends can show progress.

The shape of that loop is worth drawing once.

FIGURE 3: The reason-and-act loop

goal
 |
 v
+--------+      +-----------+      +------------------+
| THINK  | --\> | ACT       | --\> | OBSERVE          |
| (model | \<-- | call a    | \<-- | tagged result,   |
|  plans)|      | tool      |      | emit events      |
+--------+      +-----------+      +------------------+
   |                                      |
   |  goal met?  no --------------------- +
   |  yes
   v
answer

Other patterns are available for other shapes of problem, including plan-and-execute for tasks that benefit from an explicit plan up front and reflexion for tasks where the agent should check and retry its own work.

Interactive controls let a user stop a run, retry it, undo a step, compress the context when it grows long, or roll back to an earlier checkpoint. For problems too large for one agent, Mercury can spawn subagents with restricted tool sets, and it can run multi-agent workflows defined declaratively in YAML, in shapes such as a pipeline, a round-robin, a coordinator with workers, a tree, a map-reduce, a router and a structured debate.

Two capabilities turn Mercury from a clever console into something that lives in your daily environment. The scheduler lets you express recurring work either in plain language, such as asking for a task every weekday morning, or in cron syntax, and it runs an agent task on that schedule.

The gateway connects the agent to the chat applications people already use, with sixteen adapters spanning Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Teams, IRC, email, SMS, Home Assistant, RSS, a generic webhook, a web chat widget and the command line itself.

The gateway handles the realities of messaging, including pairing a direct-message conversation to an account with a code, carrying context across platforms, transcribing voice memos and triaging messages to a human when appropriate.

HOW MERCURY IS EXTENDED


Extensibility is where the small-interfaces principle pays its largest dividend, because adding capability almost always means implementing one more object behind one more interface rather than modifying the core.

The most common extension is a new tool, and a tool is just an object that declares its name, a description the model reads to decide when to use it, the toolset it belongs to, a JSON schema for its arguments, a risk level and an execute function.

A helper named defineTool exists purely to give you type inference while you write it. The following example is a complete, well-behaved tool that reverses a string; it is trivial on purpose so that the shape, rather than the logic, is what you take away.

CODE: a minimal custom tool

import { defineTool } from '@mercury/core';

/\*\*
 * A minimal, safe tool. "risk: safe" means it never needs
 * approval, and because it does not touch the outside world
 * it is not marked sensitive.
 \*/
export const reverseText = defineTool\<{ text: string }\>({
  name: 'reverse\_text',
  description: 'Reverse the characters of the given text.',
  toolset: 'utility',
  risk: 'safe',
  parameters: {
type: 'object',
properties: {
 text: { type: 'string', description: 'Text to reverse.' },
},
required: ['text'],
  },
  async execute(args) {
const reversed = [...args.text].reverse().join('');
return { content: reversed };
  },
});

The risk level in that definition is not decoration; it feeds directly into the security model. A tool marked safe runs without ceremony, a moderate or dangerous tool can be gated behind approval, and a tool that produces output from the outside world marks that output untrusted so it cannot silently trigger further sensitive actions.

A tool that acts on the outside world, by writing files or sending messages or executing code, is marked sensitive and is blocked when the surrounding context is untrusted unless a human approves it.

The execute function receives, besides its typed arguments, a context object carrying the authenticated principal, the working directory inside the filesystem jail, an event emitter and a function to request approval, so even an ambitious tool interacts with the platform through a small, safe surface.

Not every capability should be written in TypeScript, and Mercury accepts that gracefully through polyglot tools. A tool can be implemented in any language, such as Python, Go, Java or C++, by placing the executable alongside a small descriptor file named tool.json that declares its name, description and parameter schema; Mercury then runs it as a subprocess and exposes it to the agent exactly like a native tool.

Beyond individual tools, plugins can contribute whole bundles of extensions at once, including tools, lifecycle hooks, additional language-model providers, new reasoning patterns, workflow templates and gateway adapters, and the command line can scaffold new instances of each of these so you start from a working skeleton rather than a blank file.

The final extension dimension is interoperability through the Model Context Protocol, and this is one of Mercury's genuine differentiators because it works in both directions.

As a client, Mercury can connect to external MCP servers over standard input and output or over HTTP, and every tool those servers expose becomes available to the agent as if it had been written locally.

As a server, Mercury exposes its own tools and agents to other MCP clients, either over a streamable HTTP endpoint secured with scoped tokens or over standard input and output for local integration.

In practice this means Mercury can both consume the growing ecosystem of MCP tools and take its place within someone else's agent as a capable tool provider, which few platforms do symmetrically.

SECURITY AND OBSERVABILITY, BRIEFLY BUT SERIOUSLY


Because an agent that can read the web and then act is a security-sensitive thing, Mercury treats defence as a first-class layer rather than an afterthought. Passwords are hashed with scrypt and sessions are carried by signed JSON Web Tokens, access is governed by role-based controls, and API access can be granted through scoped tokens that carry only the permissions they need.

Prompt-injection defence is layered, with heuristics and an optional classifier inspecting input and with the untrusted-content tagging described earlier preventing externally supplied text from reaching sensitive tools unapproved.

File and terminal tools operate inside a path jail, outbound requests pass through a guard against server-side request forgery, secrets are redacted from output, and every security-relevant action is written to a hash-chained audit log whose entries cannot be altered without detection.

Observability, as noted at the outset, falls out of the event-driven core. Every run produces a trace of its events that can be replayed after the fact with the replay subcommand, a live debug stream allows breakpoints and pausing and resuming a run as it happens, and operational metrics are exposed in the Prometheus format at a metrics endpoint so the platform can be monitored with the same tools as any other service.

Taken together, these mean that when an agent does something surprising you can see exactly what it saw and decided, which is the difference between trusting a system and merely hoping it behaves.

A HONEST WORD ON MATURITY, AND WHERE TO GO NEXT


It would be easy to end an article like this on an unqualified high note, but that would misrepresent the project, and misrepresentation is the one thing an infrastructure tool can least afford.

Mercury is feature-rich and carefully structured, and its offline test suite exercises its subsystems deterministically, but it is an early release and it has not yet been run hard against the full range of real external chat platforms and voice services at production scale.

The mock provider that the tests rely on is, by deliberate design, disabled outside of testing, which is consistent with the project's stated rule that mocks are for testing and nothing else.

If you are evaluating Mercury for serious use, the right next step is to read the status document in the repository, which records subsystem by subsystem what has been verified and what has not, and then to start exactly where the red thread of this article began, by pointing Mercury at a local model you fully control, watching a run unfold through the event stream, and only then widening the circle to remote models, chat gateways and schedules as your confidence grows.

That path, from a private local model outward, is the one the whole architecture was built to make safe, observable and, with any luck, genuinely enjoyable.



To download Mercury Agent visit its GitHub project repository: Mercury Agent (https://github.com/ms1963/mercury_agent)

No comments: