ACI.dev Source on GitHub

An open-source platform connecting AI agents to external tools: 1K+ tool integrations across 100+ SaaS apps, behind one MCP server and a semantic search layer that keeps them out of the context window.


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:

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