Skip to content

Secrets

Anything LatchAI automates eventually needs a credential. The rule the secret store enforces is simple: a secret’s value never enters a file you might commit, an event you might share, or a prompt you might send to a model.

On macOS, secrets are stored in the login Keychain via the system security CLI — not in the workspace, not in git, not in any LatchAI file. Each one is a generic password under the service latchai, with the secret’s name as the account, so security sees them too:

Terminal window
security find-generic-password -s latchai -a MY_TOKEN -w # prints the value (Keychain may prompt)

A names-only index (secrets.index.json, in the LatchAI home) lets LatchAI list what exists without exposing values.

Off macOS, or if the Keychain is unavailable, the store falls back to secrets.local.json in the home, written with 0600 permissions. That fallback is a real file with real values in it, so treat it accordingly — and exclude it if you ever put your home under version control. GET /api/secrets reports which backend is live (keychain or file) alongside the names.

The secrets manager is a command-palette away: ⌘⇧PSecrets: Manage. It lists the names it knows, takes a new name and value, and deletes. It never displays a value — the API has no read endpoint for the UI to call, by construction.

  • Names are [A-Za-z0-9_.-]. A name outside that set is rejected rather than escaped.
  • Rotating is adding the same name again; the Keychain entry is updated in place, and every {{secret:NAME}} reference picks up the new value on its next use with no edits.
  • Deleting removes the entry and the index line. References to it start failing loudly (see Troubleshooting) rather than quietly sending an empty string.

Secrets are referenced as {{secret:NAME}}. The reference is what gets stored; the value is substituted by the executor at the moment of use — in an HTTP node’s URL, headers, and body, and in an MCP server’s stdio environment values and HTTP header values. The value therefore never reaches the workflow JSON on disk, the run event log, or the model’s context window.

{
"type": "http_request",
"config": {
"url": "https://api.example.com/v1/things",
"headers": { "Authorization": "Bearer {{secret:EXAMPLE_TOKEN}}" }
}
}

Substitution happens after {{nodeId}} interpolation and immediately before the call, so an upstream node’s output can build the request while the credential is still added last.

Two more defences on the same node. The response is scanned for any secret value the call used and those occurrences are replaced with [redacted], so a chatty API that echoes your token back can’t leak it into a node output that a model then reads. And a failed request reports the host onlyPOST api.example.com -> 401 — never the full URL, which may carry a token in its query string.

The same referencing works in mcp.json, which is what keeps tokens out of a file you’d otherwise happily paste into a bug report:

{
"servers": {
"tracker": {
"transport": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer {{secret:TRACKER_TOKEN}}" }
}
}
}

Keys entered in the Models page’s Connected rows are written to the secret store and referenced from latchai.config.json as {{secret:...}} rather than stored inline. Keys you don’t touch on a later save carry over automatically, so the UI never has to echo a key back to you to preserve it. The secret is named for the provider — provider.<name>.apiKey — so what you see in the config is a pointer:

{
"providers": {
"openai": {
"type": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-5.2",
"apiKey": "{{secret:provider.openai.apiKey}}"
}
}
}

run_shell executes with a secret-scrubbed environment: every variable whose name looks credential-shaped (SECRET, TOKEN, PASSWORD, PASSWD, API_KEY, CREDENTIAL, in any case) is dropped before the command runs. An agent can read env freely and still not find your keys there; anything it legitimately needs must come from the secret store by name.

It also runs with a git ceiling at the LatchAI home, so git discovery inside workspace/ cannot walk up out of it — an agent’s git add -A stages its own project, never LatchAI’s. Mounted repositories are their own git toplevels and are unaffected.

A webhook_trigger may set config.secret; callers present it with the x-latchai-secret header or a ?secret= query parameter. Combined with the engine’s loopback bind and non-local Host/Origin rejection, a webhook is local-only unless you deliberately change the bind address.

If you do expose a hook beyond the machine:

  1. Set config.secret first. It is the only authentication the hook has; the comparison is constant-time, so neither the value nor its length leaks through timing.
  2. Terminate TLS in front of it. The engine speaks plain HTTP; a bare LATCHAI_BIND on a LAN address sends that token in the clear.
  3. Keep the blast radius small. The workflow behind the hook runs with whatever tools you gave it — put the narrowest allowlist you can on any agent node it reaches.
  4. Watch it. Every fire is a run.started with source: "webhook", so unexpected traffic is visible in the Run Monitor and the run logs.

A missing secret fails differently in each of the three places it’s used, and knowing which message belongs to which saves a lot of guessing:

  • In a workflowhttp_request throws unknown secret: NAME and the node fails. The run stops; nothing is sent.
  • In a provider — the engine can’t fail the boot over it, so it logs [latchai] SECRET RESOLUTION FAILED … the raw placeholder will be sent as the API key and carries on. The symptom downstream is every model call returning 401. Check the daemon’s console (or <home>/runs/daemon.log) before you go looking at the provider’s status page.
  • In an MCP server — connection is refused with secret not set: NAME — add it in Secrets, then re-save, and that server’s tools are simply absent from the registry.