LLMs Technical Reviews

aipotheosis-labs/aci

Self-hostable FastAPI + pgvector tool-calling backend: 98 JSON-defined apps, multi-tenant OAuth/API-key accounts, KMS-encrypted secrets.

GitHub ↗★ 4.9kPythonApache-2.0commit 3e4a82f · 2026-05-28homepage ↗

Overview

ACI.dev is a multi-tenant tool-calling backend. It is a FastAPI server on PostgreSQL with pgvector, plus a Next.js developer portal. Agents use it to search, inspect and run calls against third-party APIs through one API key. In this repo, an integration is data, not code. Each app is an app.json plus a functions.json under backend/apps/. The pinned SHA has 98 apps and 987 functions: 971 of them are plain REST calls described in JSON Schema, and 16 are handled by Python “connector” classes.

The platform has a clear tenancy model. An organization owns projects. A project owns agents, and each agent has one API key. End users connect their own accounts as linked accounts, keyed by a linked_account_owner_id that you choose. Credentials are encrypted with AWS KMS envelope encryption, and OAuth2 tokens are refreshed at call time. This suits teams that build an agent product for many end users and want hosted-style OAuth on their own infrastructure. The “Unified MCP server” that the README features is not in this repo. It lives in aci-mcp, a thin client of this API, and the Python SDK is also a separate repo.

Architecture

flowchart LR
  AG["Agent / SDK / MCP client"] -->|"X-API-KEY"| API["FastAPI server"]
  P["Dev portal (Next.js)"] -->|"PropelAuth JWT"| API
  API --> RL["RateLimitMiddleware"]
  API --> SR["/functions/search"]
  API --> EX["/functions/{name}/execute"]
  SR --> PG[("Postgres + pgvector")]
  SR --> OAI["OpenAI embeddings"]
  EX --> SCM["security_credentials_manager"]
  SCM --> KMS["AWS KMS"]
  EX --> FE["get_executor"]
  FE --> REST["REST executors (httpx)"]
  FE --> CON["Connector classes"]
  REST --> TP["Third-party API"]
  CON --> TP
  CLI["aci CLI upsert-app"] --> PG
Component Path Role
API server backend/aci/server/main.py FastAPI app, middleware, routers
Function routes backend/aci/server/routes/functions.py Search, get definition, execute
Linked accounts backend/aci/server/routes/linked_accounts.py API-key, no-auth and OAuth2 account linking, plus the callback
OAuth2 backend/aci/server/oauth2_manager.py Authlib AsyncOAuth2Client wrapper (PKCE S256, refresh)
Credentials backend/aci/server/security_credentials_manager.py Resolves the scheme and credentials, refreshes expired tokens
Executors backend/aci/server/function_executors/ REST (API key / OAuth2 / no-auth) and connector executors
Connectors backend/aci/server/app_connectors/ Python classes for Gmail, E2B, Vercel, OneDrive, etc.
DB models backend/aci/common/db/sql_models.py Project, Agent, APIKey, App, Function, AppConfiguration, LinkedAccount
Encryption backend/aci/common/encryption.py, db/custom_sql_types.py KMS keyring, encrypted column types, HMAC key lookup
Integrations backend/apps/<app>/ app.json and functions.json definitions
CLI backend/aci/cli/commands/ upsert-app, upsert-functions, create-project, etc.
Portal frontend/ Next.js portal for app configs, linked accounts, playground, logs

How a request flows

  1. POST /v1/functions/{name}/execute goes through RateLimitMiddleware, which applies per-IP per-second and per-day moving windows held in process memory (ratelimit.py). Then the router dependencies validate_api_key and validate_project_quota run (main.py). The key is found by its HMAC-SHA256, not by decrypting stored keys (dependencies.py).
  2. execute_function loads the Function. It checks that the project has an enabled AppConfiguration, that the app is in the agent’s allowed_apps, that the function is enabled, and that an enabled LinkedAccount exists for the owner id (functions.py).
  3. get_security_credentials chooses the code path by scheme. For OAuth2, it refreshes the token if it has expired, keeps a rotated refresh token, and marks the result is_updated. update_security_credentials then saves the new tokens (security_credentials_manager.py).
  4. custom_instructions.check_for_violation runs only if the agent has an instruction for this function. It asks gpt-4o-mini (the default) whether the call breaks the instruction (custom_instructions.py).
  5. get_executor matches (protocol, security_scheme) to an executor (function_executors/init.py). FunctionExecutor.execute validates the input against the visible schema, adds the defaults for invisible required fields and drops None values (base_executor.py).
  6. The REST executor builds the URL from server_url + path, fills in the path params, adds the credential to the header, query, body or cookie, and sends one synchronous httpx request (10 s connect, 30 s read). An HTTP error becomes FunctionExecutionResult(success=False) (rest_function_executor.py, rest_oauth2_function_executor.py).
  7. The route logs the input and the result to Logfire, truncated if they are too large (functions.py).

Key components

Integration definitions

app.json holds the metadata and the security_schemes. OAuth client secrets are Jinja placeholders such as {{ AIPOLABS_GITHUB_APP_CLIENT_ID }} (github/app.json). Each function names itself APP__ACTION and sets protocol: "rest" with method, path and server_url. Its parameters are split into header/path/query/body objects, and a visible list controls what the LLM sees (github/functions.json). upsert-app renders the secrets, validates the result with Pydantic, embeds the app with OpenAI and upserts it. It is a dry run unless you pass --skip-dry-run (upsert_app.py). Functions are loaded by a separate upsert-functions command. Every definition is written by hand. There is no OpenAPI import.

Connector functions

When protocol: "connector" is set, the function name selects the code: GMAIL__SEND_EMAIL becomes module aci.server.app_connectors.gmail, class Gmail, method send_email. The executor imports that module with importlib and creates a new instance for each call (connector_function_executor.py, base.py).

Discovery

GET /v1/functions/search embeds the intent and orders the functions by pgvector cosine distance. It can limit the results to functions the agent may use (allowed_only), and it returns OpenAI, OpenAI Responses, Anthropic or basic schemas (functions.py, L277-L315). meta_functions.py defines ACI_SEARCH_FUNCTIONS, ACI_GET_FUNCTION_DEFINITION and ACI_EXECUTE_FUNCTION, so an agent can do this loop by itself (meta_functions.py). /v1/agent/chat is the portal playground: it streams gpt-4o with the functions you selected (agent.py).

Credentials and OAuth

link_oauth2_account creates a PKCE verifier and puts it, together with the project and owner ids, into a signed but unencrypted JWT state. Then it returns the provider URL. The callback decodes the state, checks that the client_id matches, exchanges the code and stores the linked account (linked_accounts.py). A project can replace the app’s OAuth client with its own through security_scheme_overrides (security_credentials_manager.py). Linked-account credentials sit in a JSONB column. EncryptedSecurityCredentials encrypts only the secret fields in it (secret_key, access_token, refresh_token, client_secret and the raw token response) (custom_sql_types.py). Platform API keys use the Key column type, which encrypts on write and decrypts on read through the AWS Encryption SDK with a KMS keyring (custom_sql_types.py, encryption.py).

Extending it

  • New REST integration: add backend/apps/<name>/app.json and functions.json (see INTEGRATION_GUIDE.md), then run upsert-app and upsert-functions. You write no Python.
  • Logic that JSON cannot describe: add an AppConnectorBase subclass in app_connectors/<app>.py and set protocol: "connector".
  • Agent policy: per-agent allowed_apps, per-config enabled_functions, and per-function natural-language custom_instructions.

Running it

backend/compose.yml starts pgvector/pgvector:pg17, LocalStack with only KMS enabled (an init script creates the key), a PropelAuth mock, the server on port 8000, and a runner container that runs alembic upgrade head (compose.yml). Dev mode bind-mounts a fake propelauth_fastapi module into the virtualenv. You need an OpenAI key for embeddings, and you seed apps with the CLI. In production you need a real KMS key, PropelAuth for the portal, and Stripe if you use billing. Sentry and Logfire are optional.

Strengths and caveats

  • Strength: adding a REST integration needs only declarative JSON, and the visible/invisible split hides auth and boilerplate parameters from the model.
  • Strength: the security model is real: KMS envelope encryption, HMAC key lookup, token refresh at call time, and permissions per agent and per function.
  • Caveat: the REST executor makes a blocking httpx.Client call inside an async route, and it creates a new client for each call. The code has TODO: add retry. There is no retry, backoff or pagination helper.
  • Caveat: the rate limiter is per IP and uses in-process MemoryStorage, so the limits are not shared across replicas.
  • Caveat: the PKCE code_verifier travels inside the readable state JWT, and the state has no expiry (there is a TODO). This weakens what PKCE is meant to protect.
  • Caveat: there are no triggers, webhooks for app events or data sync. The only webhooks are PropelAuth sign-up and Stripe billing. MCP and the SDK are in other repos.
  • Caveat: search, app upsert and custom-instruction checks all call OpenAI, so self-hosting is not provider-neutral.

Sources: code at 3e4a82f, deepwiki-open wiki (13 pages), OpenDeepWiki wiki (17 pages), verified Q&A.

How it answers the API layer & connectors questions

Each answer was drafted by a code-reading agent at commit 3e4a82f. Its citations were checked mechanically. Compare with the other api layer & connectors →

How is third-party authentication implemented?

answered

Third-party authentication uses three mechanisms. PropelAuth handles web portal auth (JWT tokens via HTTPBearer) — every user-facing route depends on auth.require_user (main.py:136, acl.py:15-19). API keys are for agent/programmatic access: validated by validate_api_key in dependencies.py (lines 48-69), which looks up the key via crud.projects.get_api_key, checks status (active/disabled/deleted), and returns a UUID key ID. API keys are stored encrypted at rest via the Key SQLAlchemy type decorator (custom_sql_types.py:29-48) which calls encryption.encrypt() using AWS KMS. A SHA-256 HMAC of the key is also stored for lookups. Linked accounts store end-user credentials per app per project (sql_models.py:398-455). The security_credentials JSONB column uses EncryptedSecurityCredentials (custom_sql_types.py:84-157) which encrypts individual fields (secret_key, access_token, refresh_token) via AWS KMS. The OAuth2Manager (oauth2_manager.py:16-162) handles the full OAuth2 code flow — create_authorization_url builds state-JWT-encoded redirect URLs, fetch_token exchanges codes with PKCE (S256), and refresh_token rotates expired tokens. Token expiry is checked in security_credentials_manager.py:213-216 and auto-refreshed at execution time (lines 98-132). App-level OAuth2 client credentials can be overridden per configuration via security_scheme_overrides (sql_models.py:351-354). The multi-tenant model links each API key → agent → project → org (sql_models.py:69-127).

How is an integration / connector defined?

answered

An integration (called an App) is defined declaratively as two JSON files in backend/apps/{app_name}/. The app.json file contains metadata: name, display_name, provider, version, description, categories, visibility, active status, security_schemes (with OAuth2 URLs, scopes, and placeholder client_id/client_secret as Jinja2 template variables), and default_security_credentials_by_scheme (apps/github/app.json:1-27). The functions.json file is an array of function definitions, each specifying name, description, tags, visibility, active, protocol ("rest" or "connector"), protocol_data (e.g. {"method": "GET", "path": "/repos/...", "server_url": "..."} for REST), and parameters as a JSON Schema (apps/github/functions.json:1-80). Parameters have visible and invisible properties — invisible params inject security defaults at execution time. The upsert-app CLI command (cli/commands/upsert_app.py:44-47) renders templates with environment-specific secrets, validates the JSON via AppUpsert Pydantic model, generates an OpenAI embedding for semantic search, and upserts into the apps and functions tables (upsert_app.py:84-103). There are 98 integrations in backend/apps/ (from accredible to zoho_desk). Most REST integrations are pure config; 10 have custom Python connector code in server/app_connectors/ (base.py, gmail.py, e2b.py, etc.). Versioning is a free-form string field per app; there is no automated OpenAPI codegen — integrations are manually authored.

Editor's note. Correction: upsert-app registers the app only. Functions are loaded by a separate upsert-functions command.

How are integrations exposed to LLM agents?

answered

Integrations are exposed to LLM agents via three mechanisms. Semantic search is the primary discovery path: GET /v1/functions/search accepts a natural language intent, generates an OpenAI embedding, and performs a pgvector similarity search across all function embeddings (functions.py:66-156). Results are returned in OpenAI, Anthropic, or basic formats via format_function_definition (functions.py:277-315). Meta-functions give agents a structured tool to discover tools: ACI_SEARCH_FUNCTIONS, ACI_GET_FUNCTION_DEFINITION, and ACI_EXECUTE_FUNCTION are defined as OpenAI function-calling schemas in meta_functions.py:10-83. Agents call these meta-functions to find, inspect, and invoke integrations at runtime. Agent chat (POST /v1/agent/chat) provides a streaming OpenAI-compatible chat endpoint that resolves tool definitions from named functions, converts the conversation format, and streams GPT-4o responses with tool calls (agent.py:33-68, prompt.py:62-108). The Unified MCP server is NOT in this repository — it lives in the separate aci-mcp repo (README.md:21-24), referenced as an external service that wraps the ACI.dev REST API. On the SDK side, the README references a Python SDK (aci-python-sdk). App-level filtering ensures agents only see functions from their allowed_apps and enabled app configurations (functions.py:96-131).

How is a tool call executed?

answered

Function execution is a multi-step pipeline triggered at POST /v1/functions/{function_name}/execute. The execute_function in functions.py (318-486) runs these stages: (1) Lookup — fetches the Function DB record by name. (2) Authorization chain — validates the app's AppConfiguration exists and is enabled, checks the function's app is in the agent's allowed_apps, verifies the function is in the enabled list, and confirms the LinkedAccount exists and is enabled (functions.py:364-433). (3) Credential resolution — calls security_credentials_manager.get_security_credentials which handles OAuth2 token expiry/refresh transparently (security_credentials_manager.py:33-49, 87-139). (4) Custom instruction validation — runs the function input against the agent's custom_instructions via GPT-4o (functions.py:451-456). (5) Execution — selects an executor via get_executor(function.protocol, linked_account) (function_executors/init.py:21-32). For REST protocol, RestFunctionExecutor._execute constructs an httpx request by combining the server_url, path, path params, query, header, cookie, and body from the function_input, then injects credentials and sends it (rest_function_executor.py:37-103). No sandboxing is applied — REST calls go directly to third-party APIs with a 10s/30s timeout. For the CONNECTOR protocol, ConnectorFunctionExecutor dynamically imports and instantiates a Python class from server/app_connectors/ (e.g., Gmail, E2B, Vercel) via importlib (connector_function_executor.py:31-61). BaseExecutor validates input against the JSON Schema and injects invisible default values before delegation (base_executor.py:32-82). Rate limiting is IP-based via RateLimitMiddleware (ratelimit.py:19-72) with per-second and per-day limits using a moving window. Daily/monthly quotas are enforced per project in validate_project_quota and validate_monthly_api_quota (dependencies.py:86-158). Results and errors are logged with structured telemetry to Logfire, including input/output truncation at 8KB (functions.py:239-273). There is no explicit retry or pagination mechanism in the executor — pagination is delegated to each API's own parameters.

Editor's note. Correction: the custom-instruction check defaults to gpt-4o-mini, not GPT-4o (custom_instructions.check_for_violation).

How are data sync, webhooks and triggers implemented?

not applicable

The repository does not implement scheduled data syncs, incremental cursors, webhook ingestion for external app data, or event triggers for agents. The two webhook endpoints in the repo are platform-internal: /v1/webhooks/auth/user-created (webhooks.py:24-124) handles PropelAuth user signup and auto-provisions a project and agent, verified via Svix signatures; /v1/billing/webhook (billing.py:183-200) consumes Stripe webhooks for subscription lifecycle. Neither receives external app webhook payloads (e.g., Slack events, GitHub push hooks). There is no background task scheduler, no cron-based sync, and no cursor-based incremental data sync. Each integration call is purely request-driven — the platform acts as a proxy, not a sync engine.

How is it self-hosted and what is open vs proprietary?

answered

The repository is 100% open-source under Apache License 2.0 (LICENSE:1-201). The complete backend (FastAPI server, 98+ integrations, CLI, DB models, CRUD), frontend (Next.js dev portal), and Docker Compose setup are in the repo. To self-host, you need PostgreSQL with pgvector (compose.yml:5-19), LocalStack for AWS KMS emulation (compose.yml:35-46, used for encryption key management), and PropelAuth for auth (a mock is provided for dev, compose.yml:47-56). The server depends on OpenAI for embeddings — so an API key is required. Stripe handles billing (main.py:52). Logfire and Sentry provide observability but are optional (main.py:77-85). There is no proprietary cloud dependency — the ACI.dev hosted cloud (aci.dev) is a separate managed service; everything in this repo runs independently. The Unified MCP server is explicitly NOT in this repo — it lives at github.com/aipotheosis-labs/aci-mcp (README.md:21-24) and wraps the ACI.dev API. The Python SDK is also external. The frontend dev portal requires PropelAuth for login, which is the only component that needs an external identity provider when self-hosting (the mock replaces it for local dev).