ARMANOS

Solutions

For developers

Your code drives real profiles, each with its own fingerprint, cookies, storage and proxy. A token-protected server on 127.0.0.1 opens them and hands back a debugging endpoint for that profile alone, so Puppeteer, Playwright or your own client attaches the way it would to any Chromium. Everything below is in the app today, including the part we would rather you heard from us than from a website.
50325
Local API port
11
Routes on the local API
9
MCP tools
7
API key scopes
13
Webhook events

Your code drives a real profile, not a fresh browser

A script that opens a plain Chromium starts from the machine it runs on. The screen, the fonts, the GPU string and the time zone belong to that computer, and every account you open there repeats them. A user data directory keeps cookies apart, but it cannot give the tenth account a different device.

An ARMANOS profile carries the device with it. Each one holds its own cookies, storage, extensions and proxy, plus its own fingerprint from generation 4 of the generator, built on one of five templates: macos-chrome, windows-chrome, linux-chrome, android-chrome and android-tablet. On the ARMANOS Browser engine, version 153.0.7978.0, those values are substituted inside the engine rather than painted over by a page script that a page script can find.

You attach the way you would to any Chromium: over the DevTools protocol, on loopback. Opening that port normally sets navigator.webdriver to true, which announces automation on the first line of most checks. The engine launches with --disable-blink-features=AutomationControlled, the value stays false, and a test starts the real engine and reads it from a real page.

What you needIncognito windowSpoofing extensionVirtual machineARMANOS profile
Cookies and storage kept apart per accountYesYes
Fingerprint changed below page JavaScriptYesYes
Its own proxy, not the machine'sbrowser-wideYesYes
Ten accounts open at once on a laptopas much RAM as you haveYes
Your code attaches to exactly one of themYesYes
Opens in about a secondYesYesYes

The local API lives on your machine and answers only programs

Turn the local interface on in the app, under Settings, API and MCP. The server binds to 127.0.0.1 and to nothing else. The default port is 50325, and if something already holds it, the app takes a free port from the system and reports the one it got.

It is off until you switch it on. On first run the app writes local-api.json into its data directory with a token of 24 random bytes and the switch set to off, readable by your account alone. Send the token as the token query parameter, as the x-api-token header, or as Authorization: Bearer. Regenerating it takes effect at once, with no restart.

Loopback is not a boundary on its own. The pages a person visits also run on this machine, and a domain rebound to 127.0.0.1 reaches a local server as same origin. So a request carrying an Origin header, or a Sec-Fetch-Site other than none, or a Host that is not the address the server listens on, gets 403 and nothing else. That check runs before /status, because /status names the product and its exact version, and no page has business learning that.

Answers use one envelope: code 0 with a data object on success, a negative code and a message on failure. A request body over 1 MB is dropped. A missing or wrong token is 401, an unknown route is 404, and a handler that throws is 500 with the reason.

RouteMethodWhat it does
/statusGETThe only route without a token. Answers ready and the app version.
/api/v1/profile/listGETEvery profile with scenario, group, fingerprint template, proxy summary without passwords, and whether it is open. Also served at /api/v1/browser/list.
/api/v1/browser/startPOSTOpens the profile through the same gate as the button in the window. Returns its debugging endpoint. Accepts headless for this run only.
/api/v1/browser/stopPOSTCloses every window of that profile and says how many it closed.
/api/v1/browser/activeGETThe ids of profiles that have a window open right now.
/api/v1/profile/createPOSTCreates a profile through the same licence and quota gate as the app. Returns the new profileId and the profile.
/api/v1/profile/deletePOSTMoves the profile to the recycle bin, from where the app can restore it.
/api/v1/profile/codeGETThe current six-digit code for a profile that has a 2FA key stored, with the seconds it is still valid for.
/api/v1/proxy/listGETThe saved proxy library with the result of the last reachability check. Passwords are never included.
/api/v1/flow/listGETThe automation flows on this machine and how many steps each one has.
/api/v1/flow/runPOSTRuns a flow across the profile ids you name, with a concurrency you choose.

Every open profile hands you its own debugging endpoint

POST /api/v1/browser/start goes through the same path as the button in the window: the same licence check, the same quota. You may ask for this one run to be headless, in the body or in the query string, and the profile's own setting stays as it was. The strings 0, false and empty count as no, because a query string carries text and a non-empty string is not a yes.

On the ARMANOS Browser the answer is a debugging endpoint that belongs to that profile alone. The engine starts with --remote-debugging-port=0 bound to 127.0.0.1, Chromium picks a free port and writes it, with the websocket path, into DevToolsActivePort inside that profile's directory. The app reads that file, retrying thirty times at 150 ms, and answers with debugPort and a ws://127.0.0.1 address. The endpoint exists only while the profile is open.

On the built-in Electron engine there is one debugging port for the whole app, shared by every profile, and the answer returns ws as null and says so in a note. That port is opened only when the built-in engine is the one running. An unprotected port that nothing uses is a door left open on your own machine, so it is not opened by default.

  1. 1

    Turn the local interface on

    Settings, API and MCP. Copy the token from the same screen.

  2. 2

    Ask for the profile

    POST /api/v1/browser/start with profileId, and the token in x-api-token.

  3. 3

    Read data.ws from the answer

    It names the port and the websocket path of this profile and no other.

  4. 4

    Attach and work

    The fingerprint, cookies, extensions and proxy are already in place before your first navigation.

The API and MCP screen of the ARMANOS app, showing the local address, the access token and a ready-to-paste MCP configuration.
The address, the token and the MCP configuration block, all on one screen with copy buttons.

Puppeteer and Playwright attach in two calls, and one thing changes when they do

Two calls and you are in. Ask the local API to start the profile, take data.ws from the answer, and hand it to puppeteer.connect as browserWSEndpoint or to Playwright as connect_over_cdp. The examples on the automation page are written in English and run exactly as they stand.

Now the part we would rather you heard from us. Both clients enable the Runtime and Page domains the moment they attach. Those domains make Chromium install hooks that page scripts can notice, and probes for them are standard equipment in anti-bot tooling. That is a property of the client, not of our engine, and we cannot switch it off from our side.

Our own runner never enables a domain. Runtime.evaluate, Page.navigate, Input and Network all work without enabling anything, and readiness is polled through document.readyState instead of subscribing to lifecycle events. So a sequence driven by the app leaves the same trace as a person clicking.

The builder in the app has sixteen kinds of step: open, wait, click, type, scroll, read, screenshot, key, random pause, repeat, branch, list loop, tab, cookie, raw JavaScript and wait for a request. Repeat, branch and list loop hold nested steps, up to four levels deep, five hundred steps per list and two hundred flows on a machine. Navigation is limited to http, https and about:blank, checked when a flow is saved and again at the moment it runs, because a step can build its address from a variable.

You can start those flows from outside too. GET /api/v1/flow/list returns them with their step counts, and POST /api/v1/flow/run takes a flowId, the profile ids and a concurrency. Ids it does not recognise come back named, so you can see what to fix, and a run that took zero profiles is reported as a failure rather than a success. That last detail matters: reporting success on zero used to make callers retry and open the same profiles twice.

  • An external client: Puppeteer, Playwright, Selenium

    Your language, your libraries, your error handling, attached to one profile over its own endpoint. Enables the Runtime and Page domains when it connects.

  • The builder inside the app

    Sixteen step types, no code and no observable domains. Runs on this machine, started by hand, on a schedule, or through /api/v1/flow/run from your own program.

The two SDKs wrap the server, and deliberately nothing else

Two small clients ship in the repository: packages/sdk-js for Node and packages/sdk-python for Python. The Python one imports only the standard library, because five HTTP requests should not drag a dependency tree into your project. The Node one uses the built-in fetch, so it needs Node 18 or newer, and carries hand-written type definitions.

They wrap the server API, not the app on your machine. The base address defaults to https://api.armanos.io and comes from ARMANOS_API_URL, and the key comes from ARMANOS_API_KEY. They add no logic of their own on purpose: a client that knows more than the server drifts from it within a month and starts to lie.

They check the key before the first request goes out. A key with a stray space or a character pasted in from somewhere else breaks the HTTP header itself, and the failure then reads as the server being down. Instead you get a plain message. Every error carries the HTTP status and a typed code that separates a missing key, a corrupted key, a lost connection, a rejected key and a missing right.

Starting a profile sends a device id that defaults to sdk@hostname. The lock that stops one profile opening twice, and the count of devices on your plan, both need machines to be distinguishable. One shared word for every machine broke both quietly.

Both carry a webhook verifier, and that is the half the receiving side actually needs. It recomputes HMAC-SHA256 over the timestamp, a dot and the raw body, compares in constant time on bytes, and refuses anything older than 300 seconds. Without the age check, one intercepted call would stay correctly signed forever.

profiles.list() / profiles_list()
GET /profiles
profiles.create() / profile_create()
POST /profiles
profiles.start() / profile_start()
POST /profiles/{id}/start
proxies.update() / proxy_update()
PATCH /proxies/{id}
logs.suspicion() / logs_suspicion()
GET /logs/suspicion
verifyWebhook() / verify_webhook()
HMAC-SHA256 over timestamp.body

Every path the two clients call is checked against the generated reference, so a route that changes name fails the build rather than your script.

The MCP server lets an AI client open and close your profiles

The MCP server is a single Node file with no dependencies: apps/desktop/src/mcp/armanos-mcp-server.js. Your client starts it, not the app. It speaks JSON-RPC 2.0 over stdin and stdout, reads both newline-delimited frames and Content-Length framing, and always writes newline-delimited JSON.

It holds no credentials of its own. Two environment variables point it at the local API: ARMANOS_API_URL, which defaults to http://127.0.0.1:50325, and ARMANOS_API_TOKEN. The app builds the whole configuration block for you, with the right path and the right token already in it, and puts a copy button next to it.

Nine tools, each mapped to exactly one route the local API really implements. A tool for a route that does not exist would be worse than no tool at all, because the model would keep trying it.

Three details come straight from real failures. launch_profile waits up to ninety seconds, because the engine opens its debugging port at its own pace and fifteen seconds returned "no answer" while the window was still coming up. A reply larger than 5 MB ends with a clear error instead of a promise that never resolves. Inside a Linux AppImage the script is copied next to your data first, because the path inside the image changes on every launch, and a pasted configuration would break silently at the next restart.

  • get_status

    Confirms the local API is reachable and reports the app version. The only tool that needs no token.

  • list_profiles

    Every profile with scenario, group, template, proxy summary and whether it is running.

  • get_active_profiles

    The ids of profiles with a window open right now.

  • launch_profile

    Opens a profile through the same licence and quota gate as the app, and returns the debug port when there is one.

  • stop_profile

    Closes every window of a profile and reports how many were closed.

  • create_profile

    Creates a profile, optionally with a group, template, tags, notes and a proxy, including an SSH exit.

  • delete_profile

    Moves a profile to the recycle bin, from where the app can restore it.

  • get_one_time_code

    The current two-factor code for a profile that has a key stored, with the seconds left on it.

  • list_proxies

    The saved proxy library and the result of the last check, with passwords left out.

Webhooks push events out, API keys let programs in

An API key is a door into the account without a password, so it is built like one. You create it in the dashboard, choose its rights one by one, and see the secret exactly once. The server keeps a SHA-256 hash and a ten-character prefix, so there is physically nothing to show you a second time. Twenty live keys per account is the ceiling, and a revoked key keeps its row, otherwise the audit trail would hold actions by a key nobody can name.

Rights are checked per endpoint and the default is closed. An endpoint that declares no right is unreachable with a key at all, and the reference says so in plain words; only a human login passes there. Fifteen endpoints are open to keys today, across profiles, proxies, flows and logs. The three refusals are told apart on purpose: 401 for a key that is invalid or revoked, 402 for a real key whose plan has lapsed, 403 for a real key missing a right. A script that cannot tell them apart fixes the wrong thing.

Keys belong to the paid plans: Professional, Business and Enterprise. On Free the server refuses to issue one. The plan is read from the effective entitlement on every request, not from what was true when the key was made, so a month of payment cannot buy a permanent key. The answer is held for a minute, because a program makes dozens of calls a second and two extra database reads on each is a real cost. Blocking an account stops its keys at the same moment.

Webhooks go the other way, and they are managed only by a human logged in with a password. A key that could create a subscription would become a leak that outlives its own revocation. Thirteen events, up to ten subscriptions per account, and the secret is sealed in the database rather than hashed, because it has to sign every body.

Each delivery carries X-Armanos-Event, X-Armanos-Timestamp and X-Armanos-Signature, where the signature is sha256= followed by HMAC-SHA256 over the timestamp, a dot and the exact body you received. Every delivery has its own id, because delivery is at least once and you have to be able to drop a repeat. Fields whose names look like a code, token, secret, password, link or hash never leave: an invitation code is a way in, and the path to your server is not ours to trust.

Sending is defensive. Ten seconds per attempt, three attempts, a pause of one second and then two, and a retry only when the answer could differ next time, meaning 5xx and 429. Redirects are not followed, because one redirect would turn an outside address into an inside one and undo the address check. The destination is resolved and checked before every send, not only when it was saved. Fifteen consecutive failures switch the subscription off with the reason written down, and the dashboard shows the last status, the last error and the count.

  • profile.create, profile.update, profile.delete

    A profile appeared, changed, or went to the recycle bin.

  • profile.start, profile.stop

    A profile was opened or closed on some machine of the workspace.

  • proxy.create, proxy.update, proxy.delete

    The shared proxy pool changed.

  • apikey.create, apikey.revoke

    A key was issued or closed.

  • team.invite, team.remove

    Someone was invited to the workspace or removed from it. The invitation code itself never travels.

  • plan.change

    The plan or the paid period changed.

RightWhat a key holding it may do
profiles:readList profiles with their scenario, group and fingerprint template.
profiles:writeCreate, edit and delete profiles.
browser:runTake and release the profile lock and record opens and closes. It does not launch a window on the server.
proxies:readList the shared proxy pool. Passwords are behind a separate endpoint that no key can reach.
proxies:writeAdd, edit and delete proxies in the pool.
flows:readList the flow library of the workspace. Changing it needs a human with a manager role.
logs:readRead the action log, the open and close log, sign-ins with address and country, and the suspicion rules.

The ceilings are in the code, and so is the reference

One ceiling covers the whole server API: 300 requests per 60 seconds. The sign-in surface is tighter at thirty a minute, with two of its routes at ten and three. Support messages are capped at eight per ten minutes. The counter lives in the memory of the instance.

There is no per-plan rate limit, and we will not pretend otherwise. Your plan decides how many profiles, devices and team seats you get, not how fast you may call. If you drive hundreds of profiles, pacing your own loop is your job, and a fleet that hammers one endpoint will meet the same 300 as everyone else.

The local API on your machine has no rate limit at all. It answers programs running under your own account, and the only ceiling is the 1 MB request body. The MCP server adds one of its own: a reply larger than 5 MB is refused with an explanation, which is better than a tool call that hangs forever.

The reference is generated, never written by hand. The running server publishes it at /docs, built from the same controllers that serve the requests, so it cannot promise a route that does not exist. The note about rights on each endpoint is read from the real annotation in the code rather than from a second list, and an endpoint with no annotation says plainly that keys cannot use it. The document also carries three extra fields: the list of rights, the list of webhook events, and every path a key can reach.

Server API, all endpoints
300 requests / 60 seconds
Sign-in and password routes
30 / minute, two of them 10 and 3
Support messages
8 / 10 minutes
Local API on your machine
no limit, 1 MB request body
MCP reply size
5 MB, then a clean error
API keys per account
20 live at a time
Webhook subscriptions
10 per account
Flows on one machine
200, up to 500 steps per list

Read from apps/server/src/app.module.ts, apps/desktop/src/lib/local-api.js, apps/desktop/src/lib/rpa.js and the two server services.

What this does not do

  • There is no cloud that opens a browser for you. Profiles run on your machine, through the app. The server API creates, edits, shares and locks profiles, and never launches one.
  • API keys are a paid feature. On the Free plan the server refuses to issue one, and a key whose plan has lapsed gets 402 rather than silence.
  • The two SDKs are version 0.1.0 and live in the repository. Neither is published to npm or PyPI yet, so today you copy the file into your project.
  • We cannot stop an external Puppeteer or Playwright client from enabling the Runtime and Page domains when it attaches. If the site you work with looks for that, build the sequence in the app instead.
  • There are no per-plan rate limits. One ceiling of 300 requests a minute applies to everyone, so pacing a large fleet is your own work.
  • A key cannot manage webhooks, change the flow library or read a proxy password. Those endpoints declare no right and take a human login only.

How to check

Every claim above sits in a file you can open, and most of them are held in place by a stand that runs the real code.

The local API answers programs and refuses web pages
apps/desktop/src/lib/local-api.js · apps/desktop/test/local-api-gate.js
Opening the debugging port does not set navigator.webdriver
apps/desktop/src/lib/forkEngine.js · apps/desktop/test/webdriver-flag.js, which launches the real engine and reads the value from a real page
Our own runner never enables the Runtime or Page domains
apps/desktop/src/lib/cdp.js, the DETECTION NOTE at the top of the file
Keys are hashed, scoped, and refused on the free plan
apps/server/src/api-keys/api-keys.service.ts · apps/server/src/auth/api-key.guard.ts · apps/server/test/api-keys.js
The reference matches the running server, and both SDKs run against it
apps/server/src/openapi/openapi.ts · apps/server/test/sdk-and-openapi.js
Webhook deliveries are signed, retried, and visible when they fail
apps/server/src/webhooks/webhook-dispatch.service.ts · apps/server/test/webhooks.js

Build against it today

The app is free for two profiles, and the local API is part of every plan, including that one.