This is a start-to-finish guide for building a Caraer app with the caraer CLI. You will scaffold a local project, write a function, validate it, push a build, and run it against a company.
The Caraer UI is for installing and configuring apps. Authoring — manifest, functions, webhooks, schedules, inbound routes — happens in a local folder and is deployed with caraer apps push --deploy.
- Python 3.10 or newer
- A Caraer account on a company you may develop against (use a sandbox company, not a customer tenant)
- Permission to create apps:
- Private company apps:
TOOLS_APPS_WRITE - Public marketplace apps: global Developer or Super admin (to request a Developer role, email hello@caraer.com describing the app(s) you want to build and we'll get in touch)
- Private company apps:
Decide this before you scaffold. It changes endpoints, validation, and install.
| Private | Public | |
|---|---|---|
| Create | caraer apps init --private | caraer apps init |
| Who can use it | The creating company (auto-installed on first push) | Any company after you publish |
| Listing fields | Optional | brandmark plus details (category, brand color, logo SVG) |
| Marketplace review | Not applicable | caraer publish submit |
Keep privateApp: true in caraer.json for private apps so push and pull use /api/v2/apps/private*.
pipx install caraer-cli
# or: uv tool install caraer-cli
caraer --versionFrom a local checkout: ./scripts/install.sh. That also installs shell completion.
If you use Cursor, install the app skill so the agent scaffolds V2 projects correctly:
caraer skill installcaraer auth login
# CI / password: caraer auth login --email you@example.com
caraer company list
caraer company select <company-uuid>Later commands use the selected company from your profile.
caraer apps init --name hello_inbox --label "Hello Inbox" --auth-method API_KEY
cd hello_inboxFor a company-only app:
caraer apps init --private --name hello_inbox --label "Hello Inbox" --auth-method API_KEYEvery install — API_KEY or OAUTH2 — gets a short-lived installationToken in webhook, lifecycle, and app-bar payloads. It is an inst_… Bearer, valid about one hour, and is not the long-lived API key. Call Caraer APIs with Authorization: Bearer plus that token. API_KEY still has a separate installer API key (hideApiKeyField: true hides it). Use OAUTH2 when install must run the Caraer user OAuth flow.
externalOAuthProviders is a different thing: Google, Microsoft, and similar third-party connections. See External OAuth and secrets.
init writes a V2 workspace (platformVersion: 2026.2), a sample function, lifecycle hooks, and src/app/app.caraer.yaml. Do not invent a V1 per-function GCP layout.
hello_inbox/
caraer.json # platformVersion, appUuid, runtime, privateApp
package.json # npm scripts + @caraer/client (Node)
src/app/
app.caraer.yaml # identity, auth, settings
functions/<name>/ # index.js or main.py
shared/ # helpers shared by every function
settings/*.json # optional modular settings
settings-sections/*.json # optional installer cards
lifecycle/*.json # install | uninstall | rotate | update
webhooks/*.json # platform events → function
schedules/*.json # cron → function
inbound/*.json # public HTTP → functionA folder with index.js or main.py is a function named after the folder. function.caraer.json is only needed to override the entry file or description.
caraer apps init puts this line at the top of app.caraer.yaml so the editor can load autocomplete and validation from the public schema:
# yaml-language-server: $schema=https://raw.githubusercontent.com/Caraer-HQ/caraer-app-schemas/main/schemas/app.caraer.schema.jsonThe schemas are public at Caraer-HQ/caraer-app-schemas. caraer apps validate also checks against the copies bundled with the CLI.
Open src/app/app.caraer.yaml. Set label, name, and authMethod. For a public app, replace the placeholder brandmark and details (title, description, category, logo SVG, brand color) before you submit for review.
Installation settings are for admins installing the app, not for developers:
- Prefer
SWITCH,OBJECT_SINGLE_SELECT, andSINGLE_SELECTover free text. - Do not ask for
caraer_api_baseor other platform URLs. The runtime injectsbody.caraerApiBase. - When the app writes records, declare scopes such as
records.<mapping_field>.allinstead of hard-coding object names. See Scopes, macros, and settings. - Group fields with
settingsSections. Caraer lays cards out automatically (max three across). Hide advanced fields withvisibleWhen.
caraer apps add setting inbox_label --type SINGLE_LINE --label "Inbox label"Node (default):
exports.handler = async (req, res) => {
const body = req.body || {};
return res.status(200).json({ ok: true, event: body.event || null });
};Python:
def handler(request):
body = request.get("body") or {}
return {"statusCode": 200, "body": {"ok": True, "event": body.get("event")}}The platform sends a JSON envelope on req.body (Node) or request["body"] (Python). A record.candidate.created webhook (USER_FRIENDLY, the CLI default) looks like this after Caraer merges the installation fields:
{
"event": {
"type": "Created",
"timestamp": 1774272000000,
"correlationId": "8f2c1a0e-4b3d-4c9a-9e1f-2d6b0c8a7f11"
},
"user": {
"type": "user",
"uuid": "0b1c2d3e-4f56-7890-abcd-ef1234567890",
"email": "ada@example.com",
"firstname": "Ada",
"lastname": "Lovelace",
"companyUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
},
"context": {
"companyUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
},
"record": {
"record": {
"uuid": "11111111-2222-3333-4444-555555555555",
"objectName": "candidate",
"properties": {
"first_name": "Ada",
"email": "ada@example.com"
}
},
"relations": {}
},
"appUuid": "99999999-aaaa-bbbb-cccc-dddddddddddd",
"companyUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"companyName": "Example BV",
"installationToken": "…",
"caraerApiBase": "https://api.caraer.com/api",
"settingsSchema": [
{ "name": "inbox_label", "type": "SINGLE_LINE", "value": "Main" }
],
"scopes": ["records.candidate.read"],
"secrets": {},
"connections": []
}Call Caraer APIs with Authorization: Bearer plus body.installationToken (short-lived inst_… token, not the API key) and body.caraerApiBase (no trailing slash). Flatten settingsSchema to name → value. Topic-specific data lives on event, record, user, and context — not a separate payload wrapper.
Lifecycle hooks (Installed, Updated, Uninstalled, Rotated) use a flatter envelope: event is a string, and there is no record object.
Prefer the Caraer SDKs over hand-rolled fetch / urllib calls. They are generated from the production OpenAPI spec and include both the HTTP client and webhook payload types (LifecyclePayload, WebhookPayload). Node scaffolds already add @caraer/client as a dependency.
| Runtime | Package | Install | Source |
|---|---|---|---|
| Node | @caraer/client | npm install @caraer/client | Caraer-HQ/caraer-js-client |
| Python | caraer-client | pip install caraer-client | Caraer-HQ/caraer-python-client |
API reference: developer.caraer.com. OpenAPI: v2.api.caraer.com/api-docs.yaml.
import type { WebhookPayload } from "@caraer/client";from caraer_client import WebhookPayloadPut shared code in src/app/shared/ and import it with the same relative path locally and in production:
const { flattenSettings } = require("../../shared");V2 apps deploy one container per app. Function code in the Caraer UI is rejected for build-deployed apps. Always change code locally and push again.
init already created lifecycle hooks under src/app/lifecycle/ that call on-install, on-update, on-uninstall, and on-rotate. Add the trigger you actually need:
caraer apps add function catch-created
caraer apps add webhook --topic record.candidate.created --function catch-created
caraer apps add schedule heartbeat --function catch-created --cron "0 0 */12 * * *"
caraer apps add inbound provider-push --function catch-created --auth SHARED_SECRETRecord webhooks are always object-scoped: record.{object}.{created|updated|deleted|all}. There is no company-wide record.created topic.
| Trigger | Use when |
|---|---|
Webhook (record.candidate.created, …) | A record event on that object should run your code |
| Inbound | An external system POSTs to a public URL |
| Schedule | Cron (Spring 5–6 field expression) |
| Lifecycle | Install, settings save, uninstall, or token rotate |
| App bar | A button on a record, a trait tab, or a sidebar tool. See App bars in Caraer apps |
There is no copyable inbound URL in the Caraer app today. After deploy, assemble it from the API host, the app UUID (caraer.json / caraer apps select), the route name in src/app/inbound/<name>.json, and the installing company UUID (caraer company list):
POST {caraerApiBase}/v2/public/apps/{appUuid}/inbound/{routeName}?companyUuid={companyUuid}
Content-Type: application/json
X-Caraer-Inbound-Secret: <shared-secret>companyUuid can also be sent as X-Caraer-Company-Uuid instead of a query param. For SHARED_SECRET routes, send the secret in X-Caraer-Inbound-Secret. validate warns if sharedSecret is missing.
Pass data in the JSON body. The function receives:
{
"action": "app.inbound",
"inboundRoute": "provider-push",
"body": { "hello": "caraer" }
}Query params other than companyUuid are not forwarded. Auth headers are used only to verify the caller.
Locally, caraer apps local dev prints POST http://127.0.0.1:8787/inbound/<routeName> and accepts the same JSON body.
enqueue: true (the default) returns 202 and runs the function as a job. Long work should enqueue and return quickly.
After every structural edit:
caraer apps validateFix errors before you push. Warnings (for example a missing inbound sharedSecret) can wait for the installer when you document them.
caraer apps local dev
caraer apps local test --function hello-world --sample-onlylocal dev serves POST /functions/<name> and emulates installation state, secrets, and jobs. It does not replace a deployed install for OAuth or real record events.
caraer apps push --dry-run
caraer apps push --deployThe first unlinked push creates the remote app, then uploads a build archive and waits until runtimeStatus is READY or FAILED. You will be asked for a semver greater than the previous build and optional release notes.
push syncs the manifest, functions, webhooks, schedules, inbound routes, and external OAuth providers. There is no separate upload command.
Root .env is packed into the runtime as environment variables (AFFINDA_API_KEY, …). It is not stored on the persisted build manifest. Do not commit .env. Expand ${ENV_VAR} OAuth client fields from the process environment on push.
Check status:
caraer apps status
caraer apps release version- Private apps are installed for the creating company when they are created. Open Settings → Apps in Caraer to configure settings.
- Public apps are installed from the App store in Caraer for each company that should use them.
caraer publish submitstarts marketplace review; private apps cannot be submitted.
Then:
caraer apps state get
caraer apps secrets list
caraer apps local logs --all --followTo update: edit locally, validate, push --dry-run, push --deploy. Do not hand-write remote UUIDs into YAML — push fills them.
The installation token is only half of API access. Every call the function makes as that token is checked against the scopes granted on the install. You declare what the app needs in requiredScopes. The installer can still uncheck items on the consent / install screen.
Write macros, not every leaf scope. Caraer expands them against the tenant catalog at install time (and again when settings change).
| Macro | Expands to |
|---|---|
tools.forms.all | Every tools.forms.* scope |
records.candidate.all | Record-level scopes for candidate (create / read / update / delete). Does not include property or relation scopes |
records.candidate.properties_all | Every records.candidate.property.* scope |
records.candidate.relations_all | Every records.candidate.relation.* scope |
records.*.all | Record-level scopes for every object. Use sparingly |
global.all | Every global.* scope |
A typical “this app fully owns one object” pack is the three records.<object>.* macros together. The CLI wizard can add that pack in one step.
Concrete scopes (tools.apps.read) are fine when you need one permission only. tools.objects_schemas.write is the usual extra if install creates objects.
Do not hard-code records.candidate.all when the installer chooses the object. Put the setting name in angle brackets. Caraer substitutes the selected object when settings are saved:
settingsSchema:
- name: attendee_object
label: Attendee object
type: OBJECT_SINGLE_SELECT
required: true
- name: candidate_mapping
label: Candidate
type: MAPPING
required: true
requiredScopes:
- tools.objects_schemas.write
- records.<attendee_object>.all
- records.<attendee_object>.properties_all
- records.<attendee_object>.relations_all
- records.<candidate_mapping>.all
- records.<candidate_mapping>.properties_all
- records.<candidate_mapping>.relations_allHow substitution works:
<fieldName>must match asettingsSchemaname.OBJECT_SINGLE_SELECT/OBJECT_MULTI_SELECTuse the selected object name (multi-select expands to one scope set per object).MAPPINGusesmappingValue.objectName, not the mapping items.- An empty or unset field grants no extra record scopes.
- Changing the setting updates the install: scopes for the old object are removed, scopes for the new object are added.
The function still sees the setting value on body.settingsSchema. Use that value as the object name in API calls; the token is already scoped to it.
- Settings are for admins installing the app, not for you. Keep required fields to the minimum that makes the app work.
- Prefer
SWITCH,OBJECT_SINGLE_SELECT,SINGLE_SELECT, andMAPPINGover free-text object or property names. - Group related fields with
settingsSections(orsrc/app/settings-sections/*.json). Caraer lays cards left-to-right, max three across. Unassigned fields land in Other settings. - Hide advanced fields behind a switch:
- name: custom_mapping
type: SWITCH
defaultValue: false
- name: field_mapping
type: MAPPING
visibleWhen:
- field: custom_mapping
operator: EQUALS
value: trueHidden fields are not required and their values are dropped.
valueScope: USERstores the value per Caraer user (calendar picks). Default isCOMPANY(one value for the install).FILE/MULTI_FILEbelong on app-bar action dialogs, not as everyday install settings.
- Return quickly from webhooks and inbound handlers. Enqueue
POST {caraerApiBase}/v2/apps/{appUuid}/installation/jobsfor work that can exceed the 60s function timeout. - Set
waitUntilComplete: trueonlifecycle/install.json(andupdate.jsonif settings save must show hook-written mappings) so the install request waits for the function. - Put shared helpers in
src/app/shared/and importrequire("../../shared")from a function folder. Do not copy files. - Read third-party tokens from
body.secrets/body.connections. Root.envis for your deploy-time keys (GOOGLE_CLIENT_SECRET), not installer secrets. See External OAuth and secrets.
authMethod is how the company installs the Caraer app. externalOAuthProviders is how an installer (or each user) connects a third-party account such as Google Calendar. They are independent: a typical sync app is authMethod: API_KEY plus one or more external providers.
There are also two kinds of secret:
| Kind | Where | Who sees it | Example |
|---|---|---|---|
| Deploy-time env | Root .env, packed into the container | Your function as process.env / os.environ | GOOGLE_CLIENT_SECRET |
| Installation vault | Encrypted on the HAS_APP install | Injected on body.secrets at invoke time | gmail_access_token, your own API keys |
Do not commit .env. Do not put OAuth client secrets in settingsSchema.
externalOAuthProviders:
- name: gmail
label: Google Calendar
connectionOwner: USER # or COMPANY (default)
authorizeUrl: https://accounts.google.com/o/oauth2/v2/auth
tokenUrl: https://oauth2.googleapis.com/token
clientId: ${GOOGLE_CLIENT_ID}
clientSecret: ${GOOGLE_CLIENT_SECRET}
scopes:
- https://www.googleapis.com/auth/calendar.events
- https://www.googleapis.com/auth/calendar.readonly
- https://www.googleapis.com/auth/userinfo.email${ENV_VAR} fields expand on caraer apps push from the process environment or root .env. The CLI never writes the raw client secret into the persisted manifest.
connectionOwner | Who connects | Tokens on invoke |
|---|---|---|
COMPANY (default) | One shared account per install | body.secrets.gmail_access_token / gmail_refresh_token, and body.connections[] |
USER | One account per Caraer user | body.connections[] (plus connection-scoped keys in body.secrets) |
Connect is not a lifecycle webhook. The installer uses the Connect button in Caraer, or your code starts it:
POST {caraerApiBase}/v2/apps/{appUuid}/installation/oauth/{provider}/start?redirectUri=...
Authorization: Bearer <installationToken>That returns { "authorizeUrl" }. Open it in the browser. After callback, Caraer stores tokens in the vault and refreshes them before the next invoke when they are close to expiry.
Status and revoke:
GET {caraerApiBase}/v2/apps/{appUuid}/installation/connections
DELETE {caraerApiBase}/v2/apps/{appUuid}/installation/connections/{providerOrConnectionId}body.connections[] looks like:
{
"id": "…",
"provider": "gmail",
"ownerType": "USER",
"ownerUserUuid": "…",
"accountLabel": "ada@example.com",
"connectedAt": 1774272000000,
"accessToken": "ya29.…"
}Connection-scoped vault keys (always present after connect):
oauth.{provider}.{connectionId}.access_tokenoauth.{provider}.{connectionId}.refresh_tokenoauth.{provider}.{connectionId}.access_token_expires_at
For COMPANY connections Caraer also mirrors the legacy aliases {provider}_access_token, {provider}_refresh_token, and {provider}_access_token_expires_at.
Per-user picks (which calendars to sync) belong in settings with valueScope: USER, saved via PUT {caraerApiBase}/v2/apps/{appUuid}/installation/settings/user. That fires app.updated with userSettingsChanged: true.
Use the vault for tokens and keys the function needs at runtime that are not your OAuth client credentials.
Caraer injects the decrypted map as body.secrets on webhook, lifecycle, and app-bar invokes (empty object when none are set). The HTTP API is write-oriented so a leaked Bearer cannot dump values:
| Method | Path | Returns |
|---|---|---|
GET | /v2/apps/{appUuid}/installation/secrets | Secret names only |
PUT | /v2/apps/{appUuid}/installation/secrets/{name} | Body { "value": "…" } |
DELETE | /v2/apps/{appUuid}/installation/secrets/{name} | — |
caraer apps secrets listCap is 64 secrets per install. Prefer the injected body.secrets in a handler; call PUT when you need to persist a token you obtained yourself (for example a provider that is not an externalOAuthProviders entry).
Related install APIs (same Bearer):
| Method | Path | Purpose |
|---|---|---|
GET / PUT | /v2/apps/{appUuid}/installation/state | Cursors, watermarks (max 256KB) |
POST | /v2/apps/{appUuid}/installation/jobs | { "functionName", "payload", "delaySeconds"? } |
local dev emulates state, secrets, and jobs. It does not run the real provider OAuth callback.
- Invent V1 per-function Cloud Function layouts (
platformVersion: 2026.1) unless you are maintaining a legacy app. - Copy helper files into every function folder. Use
src/app/shared/. - Put developer-only knobs (API base URLs, feature flags for you) in
settingsSchema. - Hard-code
records.candidate.allwhen the installer picks the object. Userecords.<setting_name>.allinstead. - Request
records.*.all(or the property/relation wildcards) unless the app truly needs every object in the tenant. - Skip
validatebeforepush. - Edit function code in the Caraer UI on a build-deployed app.
- Commit
.envor put rawclientSecretvalues in YAML. Use${ENV_VAR}. - Treat
authMethod: OAUTH2as the way to connect Google. That is Caraer user consent. Third-party accounts areexternalOAuthProviders.