Every agent framework reinvents tool integrations, and every integration re-solves the same three problems: OAuth, schema translation, and the fact that a raw REST API is not a good tool.
ACI.dev does it once. I owned its architecture and releases from design through ship, and grew it to 1K+ agent-ready tools across 100+ SaaS platforms.
Table of contents
The problem nobody mentions until 200 tools
You cannot put a thousand tool definitions in a context window. A single well-described function schema runs 300–800 tokens; a thousand of them is the whole budget, before the user has said anything.
Every platform hits this wall. The usual answers are to make the developer pre-select tools per agent (which means the agent can only do what you predicted) or to cram in as many as fit (which degrades selection accuracy long before it runs out of room).
ACI’s answer is three meta-functions. The agent doesn’t get a thousand tools. It gets three, and uses them to find the rest:
sequenceDiagram
participant LLM
participant ACI as ACI server
participant PG as PostgreSQL + pgvector
participant API as SaaS API
Note over LLM: context holds 3 tools, not 1000
LLM->>ACI: ACI_SEARCH_FUNCTIONS<br/>intent: "email the team about the outage"
ACI->>ACI: embed intent
ACI->>PG: ORDER BY embedding <=> intent_embedding
PG-->>ACI: ranked candidates
ACI-->>LLM: GMAIL__SEND_EMAIL, SLACK__POST_MESSAGE, …
LLM->>ACI: ACI_GET_FUNCTION_DEFINITION<br/>GMAIL__SEND_EMAIL
ACI-->>LLM: full JSON Schema — only now, only this one
LLM->>ACI: ACI_EXECUTE_FUNCTION<br/>name + arguments
ACI->>ACI: validate against visible params<br/>inject invisible defaults<br/>resolve credentials
ACI->>API: authenticated HTTP call
API-->>ACI: response
ACI-->>LLM: result
Three properties fall out of this that are worth stating plainly:
Schemas are paid for on demand. SEARCH returns names and one-line descriptions; the 500-token JSON Schema only enters context for the function actually being called.
Search is semantic, not lexical. functions.embedding and apps.embedding are pgvector columns, ranked by cosine distance against the embedded intent. “Email the team about the outage” finds GMAIL__SEND_EMAIL without the word “Gmail” appearing anywhere.
The tool catalogue can grow without touching the agent. Adding the 1001st integration changes a database row, not a prompt. This is the property that made growing to 1K+ tools an operational task rather than a re-architecture.
The unified MCP server
The second structural decision: rather than shipping a client SDK per framework and per coding agent, everything sits behind one MCP server.
flowchart TD
subgraph Consumers
CC["Claude Code"]
CX["Codex"]
CU["Cursor"]
SDK["AI SDK · CAMEL-AI · custom"]
PG2["Agent playground"]
end
CC --> MCP
CX --> MCP
CU --> MCP
SDK --> REST["REST API"]
PG2 --> REST
MCP["Unified MCP server"] --> Core
REST --> Core
Core["ACI server — FastAPI"] --> Exec["Function executors"]
Core --> Obs["Observability<br/>call logs + traces"]
Core --> DB[("PostgreSQL + pgvector")]
Exec --> SaaS["100+ SaaS APIs"]
MCP was still young when we bet on it. The bet was that a protocol boundary beats N integrations, and it held: Claude Code, Codex, Cursor and framework users all reach the same catalogue without anyone writing an adapter per consumer.
The unglamorous half was observability. Tool calling fails in ways ordinary API monitoring can’t see — the HTTP call returns 200, the model just used the tool wrong. So function-calling logs and full call traces are a first-class product surface, not a debug log.
Executing a function is not “make the HTTP call”
Between “the model emitted arguments” and “an HTTP request leaves the building” there’s a pipeline, and each stage exists because of a specific failure.
flowchart TD
In["function_input from the LLM"] --> V{"validate against<br/>VISIBLE parameters only"}
V -->|fails| Err["InvalidFunctionInput<br/>back to the model"]
V -->|passes| D["inject required-but-invisible defaults"]
D --> N["strip None values"]
N --> R{"route by Protocol"}
R -->|"REST + OAuth2"| E1["rest_oauth2_function_executor"]
R -->|"REST + API key"| E2["rest_api_key_function_executor"]
R -->|"REST, no auth"| E3["rest_no_auth_function_executor"]
R -->|"connector"| E4["connector_function_executor"]
E1 --> C["inject security credentials<br/>from the linked account"]
E2 --> C
E3 --> C
E4 --> C
C --> Call["HTTP call to the SaaS API"]
The visible / invisible parameter split is the piece I’d point at. A real API endpoint has parameters the model should choose (recipient, subject, body) and parameters it must never see, let alone set — API versions, tenant identifiers, fixed headers, auth material. Validating the model’s input against only the visible subset, then injecting the invisible required defaults afterwards, means a malformed model output fails with a message it can act on, and a hostile one can’t reach the fields that matter.
Executors are a small class hierarchy over one execute() template, so the validate-inject-strip pipeline is written once and the subclass only implements the transport. Adding a protocol means adding an executor, not editing a switch.
Database schema
PostgreSQL with SQLAlchemy, and pgvector for the semantic layer. The shape that matters is the three-layer auth model — it’s what makes multi-tenant OAuth tractable.
erDiagram
projects ||--o{ agents : has
projects ||--o{ app_configurations : configures
projects ||--o{ linked_accounts : owns
agents ||--o{ api_keys : authenticates
apps ||--o{ functions : exposes
apps ||--o{ app_configurations : "configured by"
apps ||--o{ linked_accounts : "linked via"
linked_accounts ||--o{ secrets : stores
plans ||--o{ subscriptions : priced_by
projects {
uuid id PK
uuid org_id
string name
enum visibility_access
int daily_quota_used
int api_quota_monthly_used
timestamp api_quota_last_reset
}
agents {
uuid id PK
uuid project_id FK
string name
text description
string_array allowed_apps
jsonb custom_instructions
}
api_keys {
uuid id PK
uuid agent_id FK
string key "encrypted at rest"
string key_hmac UK "lookup index"
enum status
}
apps {
uuid id PK
string name UK
string provider
string version
string_array categories
jsonb security_schemes "OAuth2 / api_key / none"
jsonb default_security_credentials_by_scheme
vector embedding "semantic search"
bool active
}
functions {
uuid id PK
uuid app_id FK
string name UK "APP__FUNCTION"
text description
enum protocol
jsonb protocol_data "endpoint, method, headers"
jsonb parameters "JSON Schema, visible + invisible"
jsonb response
vector embedding "semantic search"
bool active
}
app_configurations {
uuid id PK
uuid project_id FK
uuid app_id FK
enum security_scheme "the one chosen"
jsonb security_scheme_overrides
bool all_functions_enabled
string_array enabled_functions
bool enabled
}
linked_accounts {
uuid id PK
uuid project_id FK
uuid app_id FK
string linked_account_owner_id "the END USER"
enum security_scheme
jsonb security_credentials "encrypted"
timestamp last_used_at
bool enabled
}
secrets {
uuid id PK
uuid linked_account_id FK
string key
bytea value "encrypted"
}
The three-layer auth model
This is the part I’d want a reviewer to look at, because the naive version of this schema doesn’t work.
flowchart TD
A["<b>apps</b><br/>security_schemes<br/><i>what this SaaS supports</i>"] --> B
B["<b>app_configurations</b><br/>security_scheme + overrides<br/><i>which one THIS project uses,<br/>with its own OAuth client</i>"] --> C
C["<b>linked_accounts</b><br/>security_credentials<br/><i>the token for THIS end user</i>"] --> D
D["executor injects credentials<br/>into the outbound call"]
Three layers because three different parties decide three different things:
apps.security_schemesis a property of Gmail — it supports OAuth2, here are the scopes. Platform-owned, one row for everyone.app_configurationsis a property of a project. Which scheme this project uses, andsecurity_scheme_overridesso a customer can bring their own OAuth client ID rather than consenting to ours. This is the row that makes white-labelling possible, and collapsing it into either neighbour is what makes a platform un-white-labellable later.linked_accountsis a property of an end user of the customer’s product — keyed bylinked_account_owner_id, an opaque string the customer supplies. This is why agents can act on behalf of thousands of distinct users without ACI ever knowing who they are.
Four more decisions in here:
api_keys stores both key and key_hmac. The key itself is encrypted at rest through a custom SQLAlchemy type; the HMAC is a deterministic unique-indexed column so authentication is a single index lookup instead of decrypting every row. Encrypting a value you also need to search by is the mistake this avoids.
functions.name is globally unique and namespaced as APP__FUNCTION. The model emits a name, and the name alone must resolve — no (app_id, name) tuple to thread through the prompt, no ambiguity when two SaaS products both have a SEND.
parameters is JSONB holding a full JSON Schema, not a normalized parameters table. Parameters are read whole, validated whole by jsonschema, and passed whole to the model. A normalized table buys query power nothing uses and costs a join and a reassembly on the hottest path in the system.
app_configurations carries both all_functions_enabled and enabled_functions. The common case — “this project gets everything Gmail offers” — stays true as new functions are added to the app, instead of silently missing them because the list was snapshotted at configuration time.
Quotas and billing
projects carries its own counters — daily_quota_used, api_quota_monthly_used, api_quota_last_reset — rather than deriving usage by aggregating a log table. Quota is checked on the hot path of every function call; a COUNT(*) over call logs there would be the first thing to fall over. The log table stays the audit trail, and the counter stays the gate.
Billing runs through Stripe, with a processed_stripe_events table doing idempotency by unique event_id — webhooks are at-least-once, and the second delivery of a subscription event must be a no-op.
Also worked on
The DevOps Agent and MCP Gateway, plus the web-based agent playground for trying the catalogue without writing code.
Stack
Python · FastAPI · SQLAlchemy · PostgreSQL + pgvector · Alembic · Next.js · MCP · Stripe · jsonschema