One door for every agent
An agent holds one factory key and nothing else. With it the agent reads its settings, calls models and calls tools, always through Agent Factory. Agent Factory adds the real keys on the way and records every call.
The diagram in words
Agents, such as Firewall Management, Network Device Lifecycle or your own, hold one factory key each and call models and tools only through Agent Factory. Agent Factory has a model gateway at /gateway/v1, an MCP gateway at /mcp/{slug}, and /api/runtime for config and heartbeats. It adds the real keys of the models, Azure AI Foundry in UAE North and DDA GPUs that are OpenAI compatible, and of the tools, MCP servers for FortiGate, ServiceNow and more and HTTP APIs as API tools, on the way. It records every call in PostgreSQL. People sign in to the web app to deploy, grant, block and set budgets.
Models run in Azure AI Foundry in UAE North, or on DDA's own GPUs behind any OpenAI compatible server. Tools are MCP servers, such as the tools services of the two agents CYBERAIQ AG ships, and plain HTTP APIs that Agent Factory calls as API tools.
People sign in to the web app to:
- see every agent, its state and its last heartbeat;
- grant each agent the models, MCP servers and API tools it may use, and nothing else;
- block a model, a server, a tool or a connector, switch on read only mode, or suspend an agent, so the next call is refused;
- see what every call cost in USD and AED, and stop an agent at its monthly budget;
- read the audit log of every change people made.
The parts
Agent Factory is one Node.js process with the web app in front of it. It serves the API people use through the web app and four addresses for agents, runs its own jobs, and keeps everything in PostgreSQL. Each agent runs its own interface and its own tools service.
The diagram in words
People use the web app in a browser, which passes /api on to Agent Factory, one Node.js process. For people it holds the Library, the fleet, governance, costs and sign in. For agents it holds four addresses: the model gateway at /gateway/v1, the MCP gateway at /mcp/{slug}, API tools at /mcp/api-tools, and config and heartbeat at /api/runtime. Its jobs run every 30 seconds, every 5 minutes, every 15 minutes and once a day. It keeps calls, settings and audit in PostgreSQL. Each agent has its own interface, its own loop and one factory key. Agent Factory calls Microsoft Entra ID for sign in; for models, Azure AI Foundry in UAE North in the cloud, or DDA GPUs on premises that are OpenAI compatible; each agent's MCP servers; and HTTP APIs for API tools. It adds each one's key on the way. It runs with Docker Compose on one server or on Kubernetes, with AKS as the reference.
For people
- Web app
- The pages people use. It passes
/apion to Agent Factory, so the browser only ever talks to one origin, and gives every page a content security policy with a new nonce on each request. - Sign in
- Microsoft Entra ID, or local accounts. Each person has one of four roles: viewer, developer, operator or admin.
- Library
- The packages CYBERAIQ AG signs, with the state of each signature, and the form that deploys a package as an agent.
- Fleet
- Every agent with its state, factory key, grants, environment, classification and budget.
- Governance and costs
- Blocks, read only mode, budgets, the classification rule, model prices, spend, and the audit log.
For agents
- Model gateway
/gateway/v1 - OpenAI compatible. It checks each call, adds the provider's key, and records the tokens and the cost.
- MCP gateway
/mcp/{slug} - One address for each registered MCP server. It checks each tool call, adds the server's key, and records the call.
- API tools server
/mcp/api-tools - One MCP server made of the API tools granted to the agent. Agent Factory makes each HTTP call itself.
- Runtime
/api/runtime - The config tells the agent what it may use; the heartbeat tells Agent Factory the agent is alive and healthy.
Jobs inside the process
- Every 30 seconds, agents silent for 3 minutes go offline.
- Every 5 minutes, each MCP server that is not blocked is asked for its tools.
- Every 15 minutes, each model provider is tested.
- Once a day, model calls, tool calls and status events older than the retention period are deleted (90 days unless an admin changes it). Audit events are kept at least 365 days.
What Agent Factory calls
- Azure AI Foundry in UAE North, on its OpenAI v1 address, with an API key or a Microsoft Entra ID token.
- DDA's GPUs through any OpenAI compatible server, with a key when the server needs one.
- MCP servers over streamable HTTP: the tools services of the two agents, which reach FortiGate, Microsoft Sentinel, ServiceNow and more, and any other MCP server people register.
- HTTP APIs, as API tools.
- Microsoft Entra ID, to sign people in when an admin turns it on.
How the pieces relate
A signed package becomes an agent. People grant the agent what it may use, and everything it may use is something an operator can stop.
The diagram in words
A package, package.yaml signed with Ed25519, is deployed as an agent. An agent has one factory key, shown once and stored as a hash, an environment, a classification and an optional monthly budget in USD. People grant it model aliases, MCP servers and API tools. A model alias such as gpt-5.4-mini names a model at a provider in the cloud, such as Azure AI Foundry, or on premises, such as DDA GPUs; confidential and secret agents may use only models on premises. An MCP server, with one slug and one key, holds tools, each read or change, and a tool uses connectors such as FortiGate or ServiceNow. An API tool is one HTTP call, and it may use secrets, encrypted with aes-256-gcm, and connectors.
An agent has one factory key, an environment (production, staging or development), a classification (public, internal, confidential or secret) and, if someone sets one, a monthly budget in USD. It comes from a package in the Library, or a developer registers one of their own.
A model alias such as gpt-5.4-mini names one model at one provider. A provider is hosted in the cloud or on premises, and that is what the classification rule looks at. Each agent has a default model and may be granted more.
An MCP server holds tools. Each tool is a read tool, which only looks, or a change tool, which can change something. A tool uses connectors, such as FortiGate or ServiceNow, so blocking one connector stops every tool that uses it. An API tool is one HTTP call that Agent Factory makes for the agent; it is read or change too, and may use secrets and connectors.
The life of an agent
From a signed package to an agent that answers, and how people stop it, start it again and replace its key.
The diagram in words
CYBERAIQ AG signs package.yaml with Ed25519. The Library checks the signature: verified, unsigned or invalid, and an invalid package cannot be deployed. An operator deploys it with a form; Agent Factory creates the agent awaiting first contact and shows its factory key once. The bundle for Docker Compose or Kubernetes starts it. Its first call turns it online. A heartbeat that says unhealthy makes it degraded; three minutes of silence make it offline. An operator can suspend it at any time, and every call is refused; on resume it is online if heard from in the last three minutes, else offline. Rotating the key shows a new key once and the old one stops; deleting removes the agent, its key and the servers it brought that no other agent uses.
- Publish and sign. CYBERAIQ AG signs
package.yamlwith Ed25519. The Library shows each package as verified, unsigned or invalid. An invalid package cannot be deployed (409 package_signature_invalid); an unsigned one can, and says so. - Deploy. An operator fills in the deploy form: name, environment, location, target, classification, default model and, if wanted, a monthly budget. Agent Factory creates the agent, awaiting first contact, and shows its factory key once. It keeps only a hash of the key.
- Bundle. The operator downloads a bundle for Docker Compose or Kubernetes: the package templates filled in, a
README.txtwith the steps, and insecrets/the key of each MCP server Agent Factory holds for it. The factory key goes in only when the request proves it holds the current key; otherwise the operator puts it insecrets/by hand. - First contact. The first call with the new key, usually the config read at start, turns the agent online.
- Heartbeat. The agent sends one every 60 seconds. One that says unhealthy makes it degraded; 3 minutes of silence make it offline.
- Rotate. A new key is shown once and the old one stops at once.
- Suspend. An operator suspends the agent with a reason, and every model and tool call it makes is refused. On resume it is online if it was heard from in the last 3 minutes, else offline.
- Delete. The agent, its key and its grants are removed, with the MCP servers it brought that no other agent uses.
How one call flows
Every call meets the same checks in the same order. The first one that fails ends the call, and Agent Factory records why.
A model call
The diagram in words
An agent posts to /gateway/v1/chat/completions with its factory key. Agent Factory checks, in order: the factory key (401 invalid_api_key), that the agent is not suspended (403 agent_suspended), that it has a model it may use (403 model_not_granted), that the model is not blocked (403 model_blocked), the classification rule (403 model_not_allowed_for_classification), the monthly budget (429 budget_exceeded), and the provider's address (403 upstream_address_not_allowed). Then it adds the provider's key and calls the model. Every call is recorded with its tokens, cost, latency, time to first token and status, refusals included.
If no model is named, or the agent is not granted the one it asks for, the agent's default model runs. The answer names the model that ran in the x-agent-factory-model header. Agent Factory builds the headers it sends upstream from scratch, so the factory key never leaves it.
A tool call
The diagram in words
An agent posts a JSON RPC tools/call to /mcp/{slug} with its factory key. Agent Factory checks the factory key (401, error -32001) and that the server is registered (404, -32004). The agent must not be suspended, the server must not be blocked, and it must be granted to the agent; each refuses the whole request with HTTP 403 and JSON RPC error -32003. Then the tool must not be blocked, none of its connectors blocked, and when read only mode is on it must be a read tool; each of these answers with a tool result marked isError, so the model can tell the person. Last the server's address is checked. Agent Factory adds the server's key and passes the call on, and records it.
A refused request, such as an unknown key or a server the agent is not granted, is a JSON RPC error with an HTTP status. A refused tool is an ordinary tool result with isError: true, so the agent and its model can read why and carry on. Blocked tools are also left out of tools/list. When read only mode is on, a tool the server never listed is refused too, because Agent Factory cannot know that it only reads.
What is recorded
| Record | What it holds | What it never holds |
|---|---|---|
| Model call | Agent, model asked for and model that ran, tokens (prompt, cached, completion, reasoning), cost in USD, latency, time to first token, status (ok, error or blocked) and the reason | The prompt or the answer |
| Tool call | Agent, server, tool, read or change, status, the reason, latency | The arguments, unless an admin turns on tool argument capture; then the first 400 characters, with anything that looks like a key removed |
| Audit event | Who changed what, when, and from where: deploys, grants, blocks, budgets, keys, settings, sign ins | Secret values |
Governance
Every control applies from the next call; nothing waits for the agent to restart or read its config again. The switches in settings take up to 3 seconds, and with more than one replica a suspension or a new key takes up to 10 seconds to reach the others.
The diagram in words
Suspending an agent refuses all its model and tool calls (agent_suspended). Blocking a model refuses calls to that model (model_blocked). Blocking an MCP server, one tool, a connector or an API tool refuses the tool calls that use it (server_blocked, tool_blocked, connector_blocked, api_tool_blocked). Read only mode, one switch for all agents, refuses every change tool call (read_only_mode). A monthly budget in USD refuses model calls once this month’s spend reaches it (budget_exceeded). The classification rule refuses cloud models to confidential and secret agents (model_not_allowed_for_classification). An agent may call only the servers and API tools it is granted (server_not_granted, api_tool_not_granted); asking for a model it is not granted runs its default model. Operators use every control; an admin may switch the classification rule, which is on from the start; a developer may grant to the agents it owns. Every refusal is recorded with its reason, and every control people use goes to the audit log.
- Blocks. An operator blocks a model, an MCP server, one tool, a connector or an API tool, with a reason. The refusal carries the reason.
- Read only mode. One switch for every agent. While it is on, only read tools run; every change tool call is refused with
read_only_mode. Model calls go on. - Budgets. A monthly budget in USD per agent. Spend is added up per calendar month in UTC. Once this month's spend reaches the budget, model calls are refused with
429 budget_exceededuntil the month turns or someone raises the budget. Tool calls are not priced and go on. - Classification rule. Confidential and secret agents may only use models hosted on premises. The rule is on from the start; an admin can switch it off.
- Grants. An agent reaches only the MCP servers and API tools it is granted.
The refusal line, as the agent sees it:
Blocked by Agent Factory: read only mode is on, and add_address_to_group can change things.The four roles
| Role | What it may do |
|---|---|
| Viewer | Reads every page, except secrets. |
| Developer | Registers agents, MCP servers and API tools of its own, which stay unshared until an operator shares them. Grants its own agents models, shared MCP servers and API tools, rotates their keys and deletes them. Saves secrets for API tools. |
| Operator | Everything a developer does, for every agent. Deploys packages, suspends and resumes agents, blocks and shares, switches read only mode, sets budgets and classifications, manages models and their prices, and replaces secrets. |
| Admin | Everything an operator does, plus people and their roles, Microsoft Entra ID sign in, model providers, and the other settings: the classification rule, the display currency and its rate, tool argument capture and retention. |
Cost
Agent Factory prices each model call from the tokens the provider reports and the prices of the model, and adds it up per agent and per month.
The diagram in words
Agent Factory prices each model call from the tokens the provider reports and the model’s prices per million tokens. Example, gpt-5.4-mini at 0.75 USD input, 0.075 cached input and 4.50 output per million: 12,000 prompt tokens of which 4,000 cached, and 800 completion tokens, cost 8,000 × 0.75 + 4,000 × 0.075 + 800 × 4.50, over a million: 0.0099 USD, which is 0.036358 AED at 3.6725, shown as AED 0.04. Reasoning tokens count as completion. An operator refreshes prices from the Azure Retail Prices API (Global, else Data Zone, else Regional; only when all three are found) or types them. Each agent’s spend is added up per calendar month (UTC); the forecast carries it on in a straight line to month end, and at the agent’s budget its model calls are refused with 429 budget_exceeded.
cost = ( (prompt - cached) x input price
+ cached x cached input price
+ completion x output price ) / 1,000,000
rounded to 6 decimals; reasoning tokens are part of completion- Prices are in USD per million tokens. An operator refreshes them from the Azure Retail Prices API, which looks for Global, then Data Zone, then Regional prices and changes nothing unless it finds all three prices, or types them in.
- Pages show money in AED at 3.6725 per USD. An admin can change the currency and the rate.
- Each agent's spend counts from the first day of the month in UTC. The forecast carries this month's pace on in a straight line to the month's end.
- A refused call costs nothing and is still recorded.
The two agents
CYBERAIQ AG ships two agents. Each has its own interface, signed in with Microsoft Entra ID, and its own tools service, split into a read server and a change server so that Agent Factory can tell them apart.
Firewall Management
The diagram in words
A person uses the Firewall Management agent in its own interface. The agent reaches its model only through Agent Factory’s model gateway, and its tools only through Agent Factory’s MCP gateway: /mcp/firewall-read with 15 read tools and /mcp/firewall-change with 15 change tools. The tools service reaches FortiGate (read and write), Microsoft Sentinel (read), Fortinet PSIRT (read) and the change register (read and write). A change tool never runs from chat until a person presses Approve. On top, a firewall write needs a change that a change approver approved in the register, inside its window, for the exact address and group, within a daily limit of 20 writes unless set otherwise.
Firewall read
/mcp/firewall-readFortiGate, Microsoft Sentinel, Fortinet PSIRT and the change register. Looks only.
15 read tools
- firewall_status
- list_firewall_rules
- find_rule_for_traffic
- get_address_group
- find_address
- denied_attempts
- ip_traffic_history
- hygiene_report
- firmware_advisories
- review_access_request
- get_change
- list_changes
- change_audit_trail
- latest_reports
- agent_activity
Firewall change
/mcp/firewall-changeRaises, assesses, submits, holds, carries out, rolls back and closes changes. Cannot approve or reject.
15 change tools
- next_work
- raise_change
- raise_removal_change
- raise_rule_change
- assess_change
- add_work_note
- submit_for_approval
- hold_change
- add_address_to_group
- tighten_rule
- remove_address_from_group
- rollback_change
- close_change
- check_config_drift
- save_report
- In chat, a change tool never runs until a person presses Approve. The agent can require an app role for that.
- On top, a firewall write needs a change that a change approver approved in the change register, inside its window, for the exact address and group, within a daily limit of 20 writes unless set otherwise.
- Its daily check is off by default. When it is on, each change tool of a scheduled run waits for a person, unless the operator sets
FW_SCHEDULE_CHANGES=auto.
Network Device Lifecycle
The diagram in words
A person uses the Network Device Lifecycle agent in its own interface. The agent reaches its model only through Agent Factory’s model gateway, and its tools only through Agent Factory’s MCP gateway: /mcp/lifecycle-read with 8 read tools and 2 that start work, and /mcp/lifecycle-change with 4 change tools. The tools reach management platforms, vendor life cycle data and vulnerability lists (read), and the CMDB and ITSM tool (read and write). A change tool runs from chat only after the person confirms it. The agent writes to the CMDB only what a person approved in the ITSM tool, and it cannot decide an approval.
Lifecycle read
/mcp/lifecycle-readThe estate from the CMDB and the management platforms, lifecycle checks and findings. Looks only, apart from starting a check and claiming the next piece of work.
8 read tools
- device_estate
- firewall_estate
- connectors
- check_status
- check_findings
- device_lifecycle
- lifecycle_changes
- change_audit_trail
2 that start work
- start_lifecycle_check
- next_work
Lifecycle change
/mcp/lifecycle-changeRaises a CMDB update change, applies what a person approved in the ITSM tool, adds work notes and closes the change. Cannot approve or reject.
4 change tools
- raise_cmdb_update_change
- apply_approved_change
- close_change
- add_work_note
- It reads 9 management platforms, such as FortiManager, Panorama and Catalyst Center, 6 sources of vendor life cycle data, such as Cisco EoX and endoflife.date, and the NVD and CISA KEV vulnerability lists.
- It writes only to the CMDB and ITSM tool: ServiceNow, Jira Service Management, Freshservice or BMC Helix.
- In chat, a change tool runs only after the person confirms it. The agent writes to the CMDB only the values of a change a person approved in the ITSM tool.
- With Agent Factory its weekly schedule is off by default.
Both agents read their config every 5 minutes and send a heartbeat every 60 seconds, and Agent Factory governs them exactly like any other agent: an operator can block either server, any tool or connector, switch on read only mode, set a budget or suspend them.
Build your own agent
Any program that can make HTTP requests can be an agent. It needs the address of Agent Factory and its factory key, and nothing else.
The diagram in words
An agent of your own talks to Agent Factory at four addresses, each with its factory key as Authorization: Bearer, x-api-key or api-key. GET {factory}/api/runtime/config at start and every 5 minutes gives its default model, the models it may use, its MCP servers with their read and change tools, and its API tools. POST {factory}/api/runtime/heartbeat every 60 seconds sends its version, interface address and health; the answer says whether it is suspended and gives a configVersion. {factory}/gateway/v1 is OpenAI compatible: models, chat/completions and embeddings. {factory}/mcp/{slug} and {factory}/mcp/api-tools speak MCP over streamable HTTP.
- A developer registers the agent in Agent Factory with its name, environment, classification and default model, and copies its factory key. It is shown once.
- The developer, or an operator, grants it any further models, MCP servers and API tools it needs.
- The program reads the key from a file or the environment and sends it as
Authorization: Bearer,x-api-keyorapi-keyon every request. It never prints it.
Both samples below use only what ships with the language. They read the config, send a heartbeat, ask the default model one question and list the tools of every MCP server the agent is granted. Any OpenAI client works for the model gateway, with the base URL from the config and the factory key as its API key, and any MCP client that speaks streamable HTTP works for the tools.
export AGENT_FACTORY_URL=https://agentfactory.dda.example
export AGENT_FACTORY_KEY_FILE=/run/secrets/factory-key
python3 agent.py "What does read only mode stop?" # Python 3.9 or later
node agent.mts "What does read only mode stop?" # Node.js 22.18 or later#!/usr/bin/env python3
"""
A small agent of your own for Agent Factory, with nothing but the Python standard library (3.9 or later).
export AGENT_FACTORY_URL=https://agentfactory.dda.example
export AGENT_FACTORY_KEY_FILE=/run/secrets/factory-key # or AGENT_FACTORY_KEY; never print it
python3 agent.py "What does read only mode stop?"
It uses the four addresses of Agent Factory, each with its factory key:
1. GET /api/runtime/config who it is, its models, its MCP servers and API tools
2. POST /api/runtime/heartbeat its version and health; the answer says whether it is suspended
3. /gateway/v1 OpenAI compatible: it asks its default model one question
4. /mcp/{slug}, /mcp/api-tools MCP over streamable HTTP: it lists the tools it may call
A real agent sends the heartbeat every heartbeatSeconds and reads the config again every few minutes.
A refusal comes back as an answer with a code; it will not change until a person changes a setting,
so the agent reports it and does not retry.
"""
import json
import os
import sys
import urllib.error
import urllib.request
FACTORY = os.environ["AGENT_FACTORY_URL"].rstrip("/")
KEY_FILE = os.environ.get("AGENT_FACTORY_KEY_FILE")
KEY = open(KEY_FILE).read().strip() if KEY_FILE else os.environ["AGENT_FACTORY_KEY"]
VERSION = "0.1.0"
MCP_VERSION = "2025-06-18"
def send(method, url, body=None, headers=None):
"""One request with the factory key. Returns (status, headers, text). A refusal is an answer too."""
request = urllib.request.Request(
url if url.startswith("http") else FACTORY + url,
data=None if body is None else json.dumps(body).encode(),
method=method,
headers={
"Authorization": "Bearer " + KEY,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {}),
},
)
try:
with urllib.request.urlopen(request, timeout=300) as response:
return response.status, response.headers, response.read().decode()
except urllib.error.HTTPError as error:
return error.code, error.headers, error.read().decode()
def rpc(url, method, params, session=None, notify=False):
"""One MCP message. The answer is JSON or an event stream; returns (status, session, message)."""
headers = {"MCP-Protocol-Version": MCP_VERSION}
if session:
headers["Mcp-Session-Id"] = session
body = {"jsonrpc": "2.0", "method": method, "params": params}
if not notify:
body["id"] = 1
status, answer_headers, text = send("POST", url, body, headers)
session = answer_headers.get("Mcp-Session-Id") or session
message = None
if "text/event-stream" in (answer_headers.get("Content-Type") or ""):
for line in text.splitlines():
if line.startswith("data:"):
data = json.loads(line[5:])
if data.get("id") == 1:
message = data
elif text.strip():
message = json.loads(text)
return status, session, message
def list_tools(name, url):
"""initialize, notifications/initialized, tools/list: the tools this agent may call there."""
status, session, message = rpc(
url,
"initialize",
{"protocolVersion": MCP_VERSION, "capabilities": {}, "clientInfo": {"name": "my-agent", "version": VERSION}},
)
if not message or "error" in message:
error = (message or {}).get("error", {})
print(f" {name}: refused, HTTP {status}, {error.get('code')}: {error.get('message')}")
return
rpc(url, "notifications/initialized", {}, session, notify=True)
status, session, message = rpc(url, "tools/list", {}, session)
tools = [tool["name"] for tool in message["result"]["tools"]]
print(f" {name}: {len(tools)} tools: {', '.join(tools)}")
def main():
question = " ".join(sys.argv[1:]) or "Say hello in five words."
# 1. Config: who the agent is, the models it may use, its MCP servers and its API tools.
status, _, text = send("GET", "/api/runtime/config")
if status != 200:
sys.exit(f"config: HTTP {status}: {text}")
config = json.loads(text)
agent = config["agent"]
print(f"{agent['name']}, {agent['environment']}, {agent['classification']}, suspended: {agent['suspended']}")
# 2. Heartbeat: its version and health. The first call with a new key brings the agent online.
status, _, text = send("POST", "/api/runtime/heartbeat", {"version": VERSION, "healthy": True, "detail": "sample"})
beat = json.loads(text)
print(f"heartbeat: HTTP {status}, suspended: {beat.get('suspended')}, configVersion: {beat.get('configVersion')}")
# 3. A model call through the gateway. Any OpenAI client works the same way:
# base_url = config["gateway"]["baseUrl"], api_key = the factory key.
gateway = config["gateway"]
status, _, text = send(
"POST",
gateway["baseUrl"] + "/chat/completions",
{"model": gateway["defaultModel"], "messages": [{"role": "user", "content": question}]},
)
answer = json.loads(text)
if status == 200:
usage = answer["usage"]
print(f"model {answer['model']}: {answer['choices'][0]['message']['content']}")
print(f"tokens: {usage['prompt_tokens']} prompt, {usage['completion_tokens']} completion")
else:
error = answer["error"]
print(f"model refused: HTTP {status}, {error['code']}: {error['message']}")
# 4. Tools: every MCP server it is granted, then its API tools. Any MCP client that speaks
# streamable HTTP works too, with the factory key in the Authorization header.
print("tools:")
for server in config["mcpServers"]:
list_tools(server["name"], server["url"])
if config["apiTools"]:
list_tools("API tools", config["apiTools"]["url"])
if __name__ == "__main__":
main()
/**
* A small agent of your own for Agent Factory, in TypeScript with nothing but Node.js 22.18 or later
* (it runs this .mts file as it is, with fetch built in).
*
* export AGENT_FACTORY_URL=https://agentfactory.dda.example
* export AGENT_FACTORY_KEY_FILE=/run/secrets/factory-key # or AGENT_FACTORY_KEY; never print it
* node agent.mts "What does read only mode stop?"
*
* It uses the four addresses of Agent Factory, each with its factory key:
* 1. GET /api/runtime/config who it is, its models, its MCP servers and API tools
* 2. POST /api/runtime/heartbeat its version and health; the answer says whether it is suspended
* 3. /gateway/v1 OpenAI compatible: it asks its default model one question
* 4. /mcp/{slug}, /mcp/api-tools MCP over streamable HTTP: it lists the tools it may call
*
* A real agent sends the heartbeat every heartbeatSeconds and reads the config again every few minutes.
* A refusal comes back as an answer with a code; it will not change until a person changes a setting,
* so the agent reports it and does not retry.
*/
import { readFileSync } from 'node:fs'
const FACTORY = (process.env.AGENT_FACTORY_URL ?? '').replace(/\/+$/, '')
const KEY_FILE = process.env.AGENT_FACTORY_KEY_FILE
const KEY = KEY_FILE ? readFileSync(KEY_FILE, 'utf8').trim() : (process.env.AGENT_FACTORY_KEY ?? '')
const VERSION = '0.1.0'
const MCP_VERSION = '2025-06-18'
type RuntimeConfig = {
agent: { id: string; name: string; environment: string; classification: string; suspended: boolean }
gateway: { baseUrl: string; defaultModel: string; models: string[] }
mcpServers: { slug: string; name: string; url: string; readOnlyTools: string[]; writeTools: string[] }[]
apiTools: { url: string; tools: string[] } | null
heartbeatSeconds: number
}
type RpcMessage = { id?: number; result?: any; error?: { code: number; message: string; data?: { reason?: string } } }
/** One request with the factory key. A refusal is an answer too, so the status is returned, not thrown. */
async function send(method: string, url: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(url.startsWith('http') ? url : FACTORY + url, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(300_000),
})
return { status: res.status, headers: res.headers, text: await res.text() }
}
/** One MCP message. The answer is JSON or an event stream. */
async function rpc(url: string, method: string, params: object, session?: string, notify = false) {
const headers: Record<string, string> = { 'MCP-Protocol-Version': MCP_VERSION }
if (session) headers['Mcp-Session-Id'] = session
const res = await send('POST', url, { jsonrpc: '2.0', method, params, ...(notify ? {} : { id: 1 }) }, headers)
let message: RpcMessage | null = null
if ((res.headers.get('content-type') ?? '').includes('text/event-stream')) {
for (const line of res.text.split('\n')) {
if (!line.startsWith('data:')) continue
const data = JSON.parse(line.slice(5)) as RpcMessage
if (data.id === 1) message = data
}
} else if (res.text.trim()) {
message = JSON.parse(res.text) as RpcMessage
}
return { status: res.status, session: res.headers.get('mcp-session-id') ?? session, message }
}
/** initialize, notifications/initialized, tools/list: the tools this agent may call there. */
async function listTools(name: string, url: string) {
const init = await rpc(url, 'initialize', { protocolVersion: MCP_VERSION, capabilities: {}, clientInfo: { name: 'my-agent', version: VERSION } })
if (!init.message || init.message.error) {
const error = init.message?.error
console.log(` ${name}: refused, HTTP ${init.status}, ${error?.code}: ${error?.message}`)
return
}
await rpc(url, 'notifications/initialized', {}, init.session, true)
const list = await rpc(url, 'tools/list', {}, init.session)
const tools: string[] = (list.message?.result?.tools ?? []).map((tool: { name: string }) => tool.name)
console.log(` ${name}: ${tools.length} tools: ${tools.join(', ')}`)
}
async function main() {
const question = process.argv.slice(2).join(' ') || 'Say hello in five words.'
// 1. Config: who the agent is, the models it may use, its MCP servers and its API tools.
const configRes = await send('GET', '/api/runtime/config')
if (configRes.status !== 200) throw new Error(`config: HTTP ${configRes.status}: ${configRes.text}`)
const config = JSON.parse(configRes.text) as RuntimeConfig
const { agent } = config
console.log(`${agent.name}, ${agent.environment}, ${agent.classification}, suspended: ${agent.suspended}`)
// 2. Heartbeat: its version and health. The first call with a new key brings the agent online.
const beatRes = await send('POST', '/api/runtime/heartbeat', { version: VERSION, healthy: true, detail: 'sample' })
const beat = JSON.parse(beatRes.text)
console.log(`heartbeat: HTTP ${beatRes.status}, suspended: ${beat.suspended}, configVersion: ${beat.configVersion}`)
// 3. A model call through the gateway. Any OpenAI client works the same way:
// new OpenAI({ baseURL: config.gateway.baseUrl, apiKey: the factory key })
const chat = await send('POST', `${config.gateway.baseUrl}/chat/completions`, {
model: config.gateway.defaultModel,
messages: [{ role: 'user', content: question }],
})
const answer = JSON.parse(chat.text)
if (chat.status === 200) {
console.log(`model ${answer.model}: ${answer.choices[0].message.content}`)
console.log(`tokens: ${answer.usage.prompt_tokens} prompt, ${answer.usage.completion_tokens} completion`)
} else {
console.log(`model refused: HTTP ${chat.status}, ${answer.error.code}: ${answer.error.message}`)
}
// 4. Tools: every MCP server it is granted, then its API tools. Any MCP client that speaks
// streamable HTTP works too, with the factory key in the Authorization header.
console.log('tools:')
for (const server of config.mcpServers) await listTools(server.name, server.url)
if (config.apiTools) await listTools('API tools', config.apiTools.url)
}
main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err)
process.exitCode = 1
})
What refusals look like
A refusal will not change until a person changes a setting, so an agent reports it and does not retry. Past the key check, the model gateway says so in the x-should-retry: false header.
{
"error": {
"message": "This agent has reached its monthly budget of 50.00 USD (spent 50.02 USD this month).",
"type": "insufficient_quota",
"code": "budget_exceeded"
}
}{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32003,
"message": "Blocked by Agent Factory: this agent has not been granted the MCP server Firewall change.",
"data": {
"reason": "server_not_granted"
}
}
}{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "Blocked by Agent Factory: the tool tighten_rule is blocked (under review)."
}
],
"isError": true
}
}{
"error": {
"code": "unauthorized",
"message": "Missing or unknown hub key."
}
}Every address, header and field is in docs/developer-guide.md. How the two agents are installed is in docs/admin-guide.md.
Security
Agents never hold a real key, people never see a stored secret, and Agent Factory never calls an address it should not.
Keys and secrets
- A factory key is
afk_and 52 random characters. Agent Factory stores only itssha256and shows the key once. - Provider keys, MCP server keys, API tool secrets and the Microsoft Entra ID client secret are encrypted with
aes-256-gcmand never sent back by the API. An API tool can use only secrets saved for API tools, as{{secret:name}}. - Upstream headers are built from scratch, so the factory key never travels to a model, an MCP server or an API.
Addresses Agent Factory will not call
Every address people type for an MCP server, a model provider or an API tool goes through one guard, when it is saved and again on every connection:
- Always refused: link local
169.254.0.0/16andfe80::/10(169.254.169.254is the metadata service on Azure, AWS, Google and Oracle), this host0.0.0.0/8and::, the Azure platform address168.63.129.16, the metadata addressesfd00:ec2::254(AWS),fd20:ce::254(Google) and100.100.100.200(Alibaba), and each of these IPv4 addresses written as IPv6 (mapped, compatible, translated and NAT64). - Refused unless
AGENT_FACTORY_ALLOW_LOCAL_UPSTREAMS=1, which is for development and tests only: loopback127.0.0.0/8and::1, in every form above. - Allowed: the private ranges and IPv6 unique local addresses where on premises tool servers, GPU servers and Kubernetes pods live. API tools also refuse
fd00::/8. - Only http and https. A name is checked inside the DNS lookup of each connection, so the address checked is the address connected to, and a name that later points somewhere forbidden cannot slip through. Redirects are never followed.
A refused address is 400 address_not_allowed when it is saved, and 403 upstream_address_not_allowed on a call.
People
- A session is a random token in an httpOnly, SameSite Lax cookie, Secure when the web app is on https. Agent Factory stores only its
sha256, and it lasts 8 hours. - Every request that changes something must carry a fixed
X-Requested-Withheader, and anOriginheader, when present, must be the web app or Agent Factory itself. - Local passwords are hashed with argon2. Sign in allows 30 attempts in 15 minutes per address and 8 per email.
- Every page has a content security policy: scripts and styles run only with that request's nonce, and no page may frame it.
Records
- Prompts and answers are never stored. Tool arguments are stored only when an admin turns on capture, cut to 400 characters with anything that looks like a key removed.
- Logs hold the method, path, status and time of each request, never headers, bodies or keys.
Known limits
- Private ranges are allowed for every upstream, so a developer can register a server or API tool at any private address Agent Factory can reach. Keep network policies tight and review new servers in the audit log.
- Any developer may name any API tool secret. Scoping secrets to their owner is not built yet.
- The encryption key cannot be rotated yet.
- Sign in limits are counted in each process, so two replicas allow twice as many attempts.
- The signature covers
package.yamlonly. - Agent Factory serves plain HTTP; TLS ends at the ingress or a proxy.
The full threat model is in docs/security.md.
Glossary
The words this page and the web app use.
- Agent
- A program that calls models and tools through Agent Factory with one factory key.
- Agent Factory
- The service that checks, prices and records every call an agent makes, and the web app people govern it with.
- API tool
- One HTTP call, described once, that agents call as an MCP tool at
/mcp/api-tools. - Audit log
- Every change people made, who made it and when. Kept at least 365 days.
- Budget
- The most an agent may spend on models in a calendar month, in USD.
- Bundle
- The files that start an agent with Docker Compose or Kubernetes, downloaded after a deploy.
- Change tool
- A tool that can change something. Read only mode refuses it.
- Classification
- How sensitive an agent's work is: public, internal, confidential or secret.
- Classification rule
- Confidential and secret agents may only use models hosted on premises.
- Config
- What
GET /api/runtime/configreturns: the agent, its models, its MCP servers and its API tools. - Connector
- A system a tool reaches, such as FortiGate or ServiceNow. Blocking it stops every tool that uses it.
- Factory key
- The one key an agent holds. It starts with
afk_. - Grant
- Permission for one agent to use one model, MCP server or API tool.
- Heartbeat
- The message an agent sends every 60 seconds with its version and health.
- Library
- The signed packages that can be deployed as agents.
- MCP server
- A server of tools that speaks the Model Context Protocol. Agents reach it at
/mcp/{slug}. - Model alias
- The name an agent asks for, such as
gpt-5.4-mini, mapped to one model at one provider. - Package
- A
package.yamlthat describes an agent, its tools services and its settings, signed with Ed25519. - Provider
- Where models run: Azure AI Foundry, or an OpenAI compatible server on premises.
- Read only mode
- One switch that lets only read tools run, for every agent.
- Read tool
- A tool that only looks.
- Refusal
- A call Agent Factory stopped, with its reason. It is recorded like any other call.
- Role
- What a person may do: viewer, developer, operator or admin.
- States
- Awaiting first contact, online, degraded, offline and suspended.
- Suspend
- Stop every call of one agent until an operator resumes it.
