aipotheosis-labs/aci
Self-hostable FastAPI + pgvector tool-calling backend: 98 JSON-defined apps, multi-tenant OAuth/API-key accounts, KMS-encrypted secrets.
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
POST /v1/functions/{name}/executegoes throughRateLimitMiddleware, which applies per-IP per-second and per-day moving windows held in process memory (ratelimit.py). Then the router dependenciesvalidate_api_keyandvalidate_project_quotarun (main.py). The key is found by its HMAC-SHA256, not by decrypting stored keys (dependencies.py).execute_functionloads theFunction. It checks that the project has an enabledAppConfiguration, that the app is in the agent’sallowed_apps, that the function is enabled, and that an enabledLinkedAccountexists for the owner id (functions.py).get_security_credentialschooses the code path by scheme. For OAuth2, it refreshes the token if it has expired, keeps a rotated refresh token, and marks the resultis_updated.update_security_credentialsthen saves the new tokens (security_credentials_manager.py).custom_instructions.check_for_violationruns only if the agent has an instruction for this function. It asksgpt-4o-mini(the default) whether the call breaks the instruction (custom_instructions.py).get_executormatches(protocol, security_scheme)to an executor (function_executors/init.py).FunctionExecutor.executevalidates the input against the visible schema, adds the defaults for invisible required fields and dropsNonevalues (base_executor.py).- 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 synchronoushttpxrequest (10 s connect, 30 s read). An HTTP error becomesFunctionExecutionResult(success=False)(rest_function_executor.py, rest_oauth2_function_executor.py). - 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.jsonandfunctions.json(seeINTEGRATION_GUIDE.md), then runupsert-appandupsert-functions. You write no Python. - Logic that JSON cannot describe: add an
AppConnectorBasesubclass inapp_connectors/<app>.pyand setprotocol: "connector". - Agent policy: per-agent
allowed_apps, per-configenabled_functions, and per-function natural-languagecustom_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.Clientcall inside anasyncroute, and it creates a new client for each call. The code hasTODO: 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_verifiertravels inside the readablestateJWT, and the state has no expiry (there is aTODO). 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?
answeredThird-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?
answeredAn 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.
upsert-app registers the app only. Functions are loaded by a separate upsert-functions command.How are integrations exposed to LLM agents?
answeredIntegrations 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?
answeredFunction 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.
gpt-4o-mini, not GPT-4o (custom_instructions.check_for_violation).How are data sync, webhooks and triggers implemented?
not applicableThe 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?
answeredThe 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).