Skip to the content
Agent Factory
Provided by

How Agent Factory works

Agent Factory is the one door between Digital Dubai's agents and the models and tools they use. Every call passes through it, so people can see each one, price it and stop it.

  • Install and upgradedocs/admin-guide.md
  • Every agent address in detaildocs/developer-guide.md
  • Threats and controlsdocs/security.md

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.

Agent Factory in one pictureAgents, 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.Peoplein the web appdeploygrantblockbudgetsAgentsone factory key eachFirewall ManagementNetwork Device LifecycleYour own agentAgent Factorychecks and records every callModel gateway/gateway/v1MCP gateway/mcp/{slug}Config and heartbeat/api/runtimeModelsAzure AI FoundryUAE NorthDDA GPUsOpenAI compatibleToolsMCP serversand moreHTTP APIsas API toolsPostgreSQLevery call recordedAgent Factory and peopleObservingChangingConfig and heartbeatKey added by Agent Factory
Agent Factory in one pictureAgents, 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.Peoplein the web appdeploygrantblockbudgetsAgentsone factory key eachFirewall ManagementNetwork Device LifecycleYour own agentAgent Factorychecks and records every callModel gateway/gateway/v1MCP gateway/mcp/{slug}Config and heartbeat/api/runtimePostgreSQLevery call recordedModelsAzure AI FoundryUAE NorthDDA GPUsOpenAI compatibleToolsMCP serversand moreHTTP APIsas API toolsAgent Factory and peopleObservingChangingConfig and heartbeatKey added by Agent Factory
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 parts of Agent FactoryPeople 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.Peoplein a browserWeb apppages for people/apiAgent Factoryone Node.js processLibraryFleetGovernanceCostsSign inModel gateway/gateway/v1MCP gateway/mcp/{slug}API tools/mcp/api-toolsConfig and heartbeat/api/runtimecloudon premisesJobs30 s5 min15 mindailyEach agentits own interfaceits own loopone factory keyMicrosoft Entra IDsign inAzure AI FoundryUAE NorthDDA GPUsOpenAI compatibleMCP serverseach agent's toolsHTTP APIsfor API toolsPostgreSQLcalls, settings, auditDocker Compose or KubernetesAKS is the referenceAgent Factory and peopleObservingChangingConfig and heartbeatKey added by Agent Factory
The parts of Agent FactoryPeople 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.Peoplein a browserWeb apppages for peopleEach agentone factory key/apiAgent Factoryone Node.js processLibraryFleetGovernanceCostsSign inModel gateway/gateway/v1MCP gateway/mcp/{slug}API tools/mcp/api-toolsConfig and heartbeat/api/runtimeJobs30 s5 min15 mindailyPostgreSQLcalls, settings, auditMicrosoft Entra IDsign inAzure AI FoundryUAE NorthDDA GPUsOpenAI compatibleMCP serverseach agent's toolsHTTP APIsfor API toolsDocker Compose or KubernetesAKS is the referenceAgent Factory and peopleObservingChangingConfig and heartbeatKey added by Agent Factory
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 /api on 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.

How the pieces relateA 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.Packagepackage.yamlEd25519deployAgentFactory keyshown once, stored as a hashEnvironmentproductionstagingdevelopmentClassificationpublicinternalconfidentialsecretBudgetUSD a month, optionalModel aliasgpt-5.4-miniAzure AI FoundrycloudDDA GPUson premisesConfidential and secret: on premises onlyMCP serverone slug, one keyToolreadchangeConnectorAPI toolone HTTP callSecretaes-256-gcmPeople deploy and grantBelongs to or uses
How the pieces relateA 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.Packagepackage.yamlEd25519deployAgentFactory keyshown once, stored as a hashEnvironmentproductionstagingdevelopmentClassificationpublicinternalconfidentialsecretBudgetUSD a month, optionalModel aliasgpt-5.4-miniAzure AI FoundrycloudDDA GPUson premisesConfidential and secret: on premises onlyMCP serverone slug, one keyToolreadchangeConnectorAPI toolone HTTP callSecretaes-256-gcmPeople deploy and grantBelongs to or uses
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 life of an agentCYBERAIQ 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.SignCYBERAIQ AG, Ed25519Checkin the LibraryDeploykey shown onceStartCompose or Kubernetesverifiedunsignedinvalidcreates the agentAwaiting first contactno call yetfirst callOnlineheard from in 3 minutesDegradedheartbeat says unhealthyOfflinesilent for 3 minutessuspendresumeSuspendedcalls refusedRotate the keynew key shown once, old one stopsDeletethe agent, its key, unshared serversPeople's stepsWhat the agent doesRefused
The life of an agentCYBERAIQ 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.SignCYBERAIQ AG, Ed25519Checkin the LibraryDeploykey shown onceStartCompose or KubernetesverifiedunsignedinvalidAwaiting first contactno call yetfirst callOnlineheard from in 3 minutesDegradedsays unhealthyOfflinesilent 3 minutessuspendresumeSuspendedevery call refusedRotate the keynew key shown once, old one stopsDeletethe agent, its key, unshared serversPeople's stepsWhat the agent doesRefused
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.

  1. Publish and sign. CYBERAIQ AG signs package.yaml with 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.
  2. 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.
  3. Bundle. The operator downloads a bundle for Docker Compose or Kubernetes: the package templates filled in, a README.txt with the steps, and in secrets/ 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 in secrets/ by hand.
  4. First contact. The first call with the new key, usually the config read at start, turns the agent online.
  5. Heartbeat. The agent sends one every 60 seconds. One that says unhealthy makes it degraded; 3 minutes of silence make it offline.
  6. Rotate. A new key is shown once and the old one stops at once.
  7. 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.
  8. 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

How a model call flowsAn 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.An agent asks for a modelPOST /gateway/v1/chat/completionsRefusals in the OpenAI error shape{"error": {"message", "type", "code"}}x-should-retry: falseafter the key checkAgent Factorychecks every call, in this orderFactory keyBearer, x-api-key or api-key header401invalid_api_keyNot suspendedoperators can suspend any agent403agent_suspendedA model it may usegranted, else its default model403model_not_grantedModel not blockedoperators can block any model403model_blockedClassificationconfidential, secret: on premises only403model_not_allowed_for_classificationBudgetthis month's spend is below it429budget_exceededProvider addressnever Agent Factory's host or cloud metadata403upstream_address_not_allowedthe provider's key, never the factory keyThe model answersAzure AI Foundry or DDA GPUsIf the provider fails502 provider_auth_failed, 504 upstream_timeout,502 upstream_unreachable, or upstream_{status}Recorded and priced, refusals tootokenscost in USDlatencytime to first tokenstatus and reasonThe model callAgent Factory's checksRefusedKey added by Agent Factory
How a model call flowsAn 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.An agent asks for a modelPOST /gateway/v1/chat/completionsAgent Factorychecks every call, in this orderFactory keyinvalid_api_key401Not suspendedagent_suspended403A model it may usemodel_not_granted403Model not blockedmodel_blocked403Classificationmodel_not_allowed_for_classification403Budgetbudget_exceeded429Provider addressupstream_address_not_allowed403the provider's key, never the factory keyThe model answersAzure AI Foundry or DDA GPUsIf the provider fails502 provider_auth_failed,504 upstream_timeout,502 upstream_unreachable,or upstream_{status}Recorded and priced, refusals tootokenscost in USDlatencytime to first tokenstatus and reasonRefusals in the OpenAI error shape{"error": {"message", "type", "code"}}x-should-retry: falseafter the key checkThe model callAgent Factory's checksRefusedKey added by Agent Factory
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

How a tool call flowsAn 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.An agent calls a toolPOST /mcp/{slug} tools/callA refused request is a JSON RPC error{"error": {"code", "message", "data": {"reason"}}}Agent Factorychecks every call, in this orderFactory keyBearer, x-api-key or api-key header401-32001Server registeredAgent Factory knows the slug404-32004Not suspendedthe agentagent_suspendedServer not blockedoperators can block any serverserver_blockedServer grantedto this agentserver_not_grantedTool not blockedblocked tools are left out of tools/listtool_blockedConnectors not blockedevery connector the tool usesconnector_blockedRead only modewhen it is on, only read tools passread_only_modeServer addressnever Agent Factory's host or cloud metadataupstream_address_not_allowed403 -32003the whole requestis refused200 isErrora tool resultthe model reads403 -32003as Agent Factoryconnectsthe server's key, never the factory keyThe MCP server answersand the result goes back to the agentIf the server fails502 or 504, JSON RPC error -32002Recorded, refusals tooArguments only if an admin turns that on: the first 400characters, scrubbed.toolread or changelatencystatus and reasonA read tool callA change tool callAgent Factory's checksRefusedKey added by Agent Factory
How a tool call flowsAn 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.An agent calls a toolPOST /mcp/{slug} tools/callAgent Factorychecks every call, in this orderThe whole request is refused: 403, -32003A tool result the model reads: 200, isErrorAs Agent Factory connects: 403, -32003Factory key-32001401Server registered-32004404Not suspendedagent_suspendedServer not blockedserver_blockedServer grantedserver_not_grantedTool not blockedtool_blockedConnectors not blockedconnector_blockedRead only moderead_only_modeServer addressupstream_address_not_allowedthe server's key, never the factory keyThe MCP server answersand the result goes back to the agentIf the server fails502 or 504,JSON RPC error -32002Recorded, refusals tooArguments only if an admin turns that on:the first 400 characters, scrubbed.toolread or changelatencystatus and reasonA refused request is a JSON RPC error{"error": {"code", "message", "data":{"reason"}}}A read tool callA change tool callAgent Factory's checksRefusedKey added by Agent Factory
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

RecordWhat it holdsWhat it never holds
Model callAgent, 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 reasonThe prompt or the answer
Tool callAgent, server, tool, read or change, status, the reason, latencyThe arguments, unless an admin turns on tool argument capture; then the first 400 characters, with anything that looks like a key removed
Audit eventWho changed what, when, and from where: deploys, grants, blocks, budgets, keys, settings, sign insSecret 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.

What people can stop, and howSuspending 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.Model callsRead toolsChange toolsOperators use every controlThe reason the agent is givenSuspend an agentevery call at once, with a reasonagent_suspendedBlock a modelfor every agent that uses itmodel_blockedBlock an MCP serverevery tool on itserver_blockedBlock one toolalso left out of tools/listtool_blockedBlock a connectorevery tool and API tool using itconnector_blockedBlock an API toolone HTTP callapi_tool_blockedRead only modeone switch for all agentsread_only_modeMonthly budgetUSD per agent, per month in UTCbudget_exceededClassification ruleconfidential, secret: on premises onlyadmin switches itmodel_not_allowed_for_classificationGrantsits servers and API toolsdevelopers, own agentsdefault runsserver_not_grantedapi_tool_not_grantedRefuses every such callRefuses the calls to what it namesAudit logevery control used, and who used itCall recordsevery refusal, with its reason
What people can stop, and howSuspending 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.ModelReadChangeOperators use every controlSuspend an agentagent_suspendedBlock a modelmodel_blockedBlock an MCP serverserver_blockedBlock one tooltool_blockedBlock a connectorconnector_blockedBlock an API toolapi_tool_blockedRead only moderead_only_modeMonthly budgetbudget_exceededClassification ruleadmin switches itmodel_not_allowed_for_classificationGrantsdevelopers, own agentsserver_not_grantedapi_tool_not_granteda model not granted: its default runsRefuses every such callRefuses the calls to what it namesAudit logevery control used, and who used itCall recordsevery refusal, with its reason
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_exceeded until 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:

A tool refused by read only mode
Blocked by Agent Factory: read only mode is on, and add_address_to_group can change things.

The four roles

RoleWhat it may do
ViewerReads every page, except secrets.
DeveloperRegisters 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.
OperatorEverything 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.
AdminEverything 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.

What a call costsAgent 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.One model callgpt-5.4-miniprompt tokens12,000of which cached4,000completion tokens800Its pricesUSD per million tokensinput0.75cached input0.075output4.50Azure Retail Prices APIor an operator types themIts costeach term over a million8,000 × 0.750.0060004,000 × 0.0750.000300800 × 4.500.003600USD0.009900AED, at 3.67250.036358shown asAED 0.04This month, per agentadded up by calendar month, UTCbudget, in USDtodayspent so farforecastday 1month end429budget_exceededfor model callsThe agent’s model callsWhat people setRefused
What a call costsAgent 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.Azure Retail Prices APIor an operator types themIts pricesUSD per million tokensinput0.75cached input0.075output4.50One model callgpt-5.4-miniprompt tokens12,000of which cached4,000completion tokens800Its costeach term over a million8,000 × 0.750.0060004,000 × 0.0750.000300800 × 4.500.003600USD0.009900AED, at 3.67250.036358shown asAED 0.04This month, per agentadded up by calendar month, UTCbudget, in USDtodayspent so farforecastday 1month end429budget_exceededfor model callsThe agent’s model callsWhat people setRefused
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.

The price of one call, in USD
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

Firewall Management, governed by Agent FactoryA 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.A personin the agent’s interfaceFirewall Managementby CYBERAIQ AGconfig every 5 minutesheartbeat every 60 secondsschedule off by defaultApprove or Canceleach change tool in chatAgent Factorychecks and records every callModel gateway/gateway/v1MCP gateway/mcp/firewall-readMCP gateway/mcp/firewall-changeoperators can stop itBlockRead only modeBudgetSuspendThe modelAzure AI Foundry or DDA GPUsFirewall toolsFirewall read15 read toolsFirewall change15 change toolsA firewall write needs a changeapproved in the registerinside its windowfor the exact address and groupa daily limit, 20 by defaultFortiGaterules, groups, firmwareMicrosoft Sentineltraffic historyFortinet PSIRTpublic advisoriesChange registerthe changes people raiseA change approverapproves it, the agent cannotModel calls, read toolsChange toolsPeopleKey added by Agent Factory
Firewall Management, governed by Agent FactoryA 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.A personin the agent’s interfaceFirewall Managementby CYBERAIQ AGconfig every 5 minutesheartbeat every 60 secondsschedule off by defaultApprove or Canceleach change tool in chatAgent Factorychecks and records every callModel gateway/gateway/v1MCP gateway/mcp/firewall-readMCP gateway/mcp/firewall-changeoperators can stop itBlockRead only modeBudgetSuspendThe modelAzure AI Foundry or DDA GPUsFirewall toolsFirewall read15 read toolsFirewall change15 change toolsA firewall write needs a changeapproved in the registerinside its windowfor the exact address and groupa daily limit, 20 by defaultFortiGaterules, groups, firmwareMicrosoft Sentineltraffic historyFortinet PSIRTpublic advisoriesChange registerthe changes people raiseA change approverapproves it, the agent cannotModel calls, read toolsChange toolsPeopleKey added by Agent Factory
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-read

FortiGate, 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-change

Raises, 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

Network Device Lifecycle, governed by Agent FactoryA 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.A personin the agent’s interfaceNetwork Device Lifecycleby CYBERAIQ AGconfig every 5 minutesheartbeat every 60 secondsschedule off by defaultConfirmeach change tool in chatAgent Factorychecks and records every callModel gateway/gateway/v1MCP gateway/mcp/lifecycle-readMCP gateway/mcp/lifecycle-changeoperators can stop itBlockRead only modeBudgetSuspendThe modelAzure AI Foundry or DDA GPUsLifecycle toolsLifecycle read8 read, 2 start workLifecycle change4 change toolsA CMDB write needs a changeapproved by a persononly its approved valuesManagement platformsFortiManager and 8 moreVendor life cycle dataCisco EoX and 5 moreVulnerability listsNVD and CISA KEVCMDB and ITSM tooland 3 moreA person approvesin the ITSM tool, the agent cannotModel calls, read toolsChange toolsPeopleKey added by Agent Factory
Network Device Lifecycle, governed by Agent FactoryA 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.A personin the agent’s interfaceNetwork Device Lifecycleby CYBERAIQ AGconfig every 5 minutesheartbeat every 60 secondsschedule off by defaultConfirmeach change tool in chatAgent Factorychecks and records every callModel gateway/gateway/v1MCP gateway/mcp/lifecycle-readMCP gateway/mcp/lifecycle-changeoperators can stop itBlockRead only modeBudgetSuspendThe modelAzure AI Foundry or DDA GPUsLifecycle toolsLifecycle read8 read, 2 start workLifecycle change4 change toolsA CMDB write needs a changeapproved by a persononly its approved valuesManagement platformsFortiManager and 8 moreVendor life cycle dataCisco EoX and 5 moreVulnerability listsNVD and CISA KEVCMDB and ITSM tooland 3 moreA person approvesin the ITSM tool, the agent cannotModel calls, read toolsChange toolsPeopleKey added by Agent Factory
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-read

The 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-change

Raises 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 four addresses your agent usesAn 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.Agent Factoryeach address takes the factory keyYour agentin any languageagent.pyPython 3.9 or lateragent.mtsNode.js 22.18 or laterIts factory keyshown only onceAuthorization: Bearerx-api-keyapi-keyConfigGET /api/runtime/configevery 5 minthe models it may useits MCP servers and API toolsHeartbeatPOST /api/runtime/heartbeatevery 60 ssends its version and healthhears if it is suspendedModel gateway/gateway/v1OpenAI compatiblemodels, chat/completions, embeddingsMCP gateway/mcp/{slug} /mcp/api-toolsMCP over streamable HTTPinitialize, tools/list, tools/callA developer registers itand copies its key, shown onceDeveloper or operatorgrants what it may useConfig and heartbeatModel calls, read toolsChange toolsPeople
The four addresses your agent usesAn 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.Your agentin any languageagent.pyPython 3.9 or lateragent.mtsNode.js 22.18 or laterIts factory keyshown only onceAuthorization: Bearerx-api-keyapi-keyAgent Factoryeach address takes the factory keyConfigGET /api/runtime/configevery 5 minthe models it may useits MCP servers and API toolsHeartbeatPOST /api/runtime/heartbeatevery 60 ssends its version and healthhears if it is suspendedModel gateway/gateway/v1OpenAI compatiblemodels, chat/completions, embeddingsMCP gateway/mcp/{slug} /mcp/api-toolsMCP over streamable HTTPinitialize, tools/list, tools/callA developer registers itand copies its key, shown onceDeveloper or operatorgrants what it may useConfig and heartbeatModel calls, read toolsChange toolsPeople
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.

  1. 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.
  2. The developer, or an operator, grants it any further models, MCP servers and API tools it needs.
  3. The program reads the key from a file or the environment and sends it as Authorization: Bearer, x-api-key or api-key on 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.

Run them
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
agent.py
#!/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()
agent.mts
/**
 * 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.

Model gateway, HTTP 429
{
  "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"
  }
}
MCP gateway, HTTP 403
{
  "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"
    }
  }
}
A refused tool, HTTP 200
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Blocked by Agent Factory: the tool tighten_rule is blocked (under review)."
      }
    ],
    "isError": true
  }
}
Runtime, HTTP 401
{
  "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 its sha256 and shows the key once.
  • Provider keys, MCP server keys, API tool secrets and the Microsoft Entra ID client secret are encrypted with aes-256-gcm and 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/16 and fe80::/10 (169.254.169.254 is the metadata service on Azure, AWS, Google and Oracle), this host 0.0.0.0/8 and ::, the Azure platform address 168.63.129.16, the metadata addresses fd00:ec2::254 (AWS), fd20:ce::254 (Google) and 100.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: loopback 127.0.0.0/8 and ::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-With header, and an Origin header, 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.yaml only.
  • 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/config returns: 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.yaml that 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.