Skip to content
Authors
  • Sem Tadema
    Sem TademaCTO

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)

Public or private

Decide this before you scaffold. It changes endpoints, validation, and install.

PrivatePublic
Createcaraer apps init --privatecaraer apps init
Who can use itThe creating company (auto-installed on first push)Any company after you publish
Listing fieldsOptionalbrandmark plus details (category, brand color, logo SVG)
Marketplace reviewNot applicablecaraer publish submit

Keep privateApp: true in caraer.json for private apps so push and pull use /api/v2/apps/private*.


1. Install the CLI

pipx install caraer-cli
# or: uv tool install caraer-cli
caraer --version

From 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 install

2. Sign in and select a company

caraer 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.

3. Scaffold a local app

caraer apps init --name hello_inbox --label "Hello Inbox" --auth-method API_KEY
cd hello_inbox

For a company-only app:

caraer apps init --private --name hello_inbox --label "Hello Inbox" --auth-method API_KEY

Every 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.

4. Know the folder

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 → function

A 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.json

The schemas are public at Caraer-HQ/caraer-app-schemas. caraer apps validate also checks against the copies bundled with the CLI.


5. Edit the manifest

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, and SINGLE_SELECT over free text.
  • Do not ask for caraer_api_base or other platform URLs. The runtime injects body.caraerApiBase.
  • When the app writes records, declare scopes such as records.<mapping_field>.all instead 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 with visibleWhen.
caraer apps add setting inbox_label --type SINGLE_LINE --label "Inbox label"

6. Write a function

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.

RuntimePackageInstallSource
Node@caraer/clientnpm install @caraer/clientCaraer-HQ/caraer-js-client
Pythoncaraer-clientpip install caraer-clientCaraer-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 WebhookPayload

Put 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.

7. Wire a trigger

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_SECRET

Record webhooks are always object-scoped: record.{object}.{created|updated|deleted|all}. There is no company-wide record.created topic.

TriggerUse when
Webhook (record.candidate.created, …)A record event on that object should run your code
InboundAn external system POSTs to a public URL
ScheduleCron (Spring 5–6 field expression)
LifecycleInstall, settings save, uninstall, or token rotate
App barA button on a record, a trait tab, or a sidebar tool. See App bars in Caraer apps

Inbound URL and params

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.

8. Validate

After every structural edit:

caraer apps validate

Fix errors before you push. Warnings (for example a missing inbound sharedSecret) can wait for the installer when you document them.

9. Run it locally

caraer apps local dev
caraer apps local test --function hello-world --sample-only

local 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.

10. Push and deploy

caraer apps push --dry-run
caraer apps push --deploy

The 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

11. Install and use it

  • 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 submit starts marketplace review; private apps cannot be submitted.

Then:

caraer apps state get
caraer apps secrets list
caraer apps local logs --all --follow

To update: edit locally, validate, push --dry-run, push --deploy. Do not hand-write remote UUIDs into YAML — push fills them.


Scopes, macros, and settings

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.

Macro scopes

Write macros, not every leaf scope. Caraer expands them against the tenant catalog at install time (and again when settings change).

MacroExpands to
tools.forms.allEvery tools.forms.* scope
records.candidate.allRecord-level scopes for candidate (create / read / update / delete). Does not include property or relation scopes
records.candidate.properties_allEvery records.candidate.property.* scope
records.candidate.relations_allEvery records.candidate.relation.* scope
records.*.allRecord-level scopes for every object. Use sparingly
global.allEvery 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.

Bind scopes to settings

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_all

How substitution works:

  • <fieldName> must match a settingsSchema name.
  • OBJECT_SINGLE_SELECT / OBJECT_MULTI_SELECT use the selected object name (multi-select expands to one scope set per object).
  • MAPPING uses mappingValue.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 UX

  • 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, and MAPPING over free-text object or property names.
  • Group related fields with settingsSections (or src/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: true

Hidden fields are not required and their values are dropped.

  • valueScope: USER stores the value per Caraer user (calendar picks). Default is COMPANY (one value for the install).
  • FILE / MULTI_FILE belong on app-bar action dialogs, not as everyday install settings.

Function habits

  • Return quickly from webhooks and inbound handlers. Enqueue POST {caraerApiBase}/v2/apps/{appUuid}/installation/jobs for work that can exceed the 60s function timeout.
  • Set waitUntilComplete: true on lifecycle/install.json (and update.json if settings save must show hook-written mappings) so the install request waits for the function.
  • Put shared helpers in src/app/shared/ and import require("../../shared") from a function folder. Do not copy files.
  • Read third-party tokens from body.secrets / body.connections. Root .env is for your deploy-time keys (GOOGLE_CLIENT_SECRET), not installer secrets. See External OAuth and secrets.

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:

KindWhereWho sees itExample
Deploy-time envRoot .env, packed into the containerYour function as process.env / os.environGOOGLE_CLIENT_SECRET
Installation vaultEncrypted on the HAS_APP installInjected on body.secrets at invoke timegmail_access_token, your own API keys

Do not commit .env. Do not put OAuth client secrets in settingsSchema.

Declare a provider

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.

connectionOwnerWho connectsTokens on invoke
COMPANY (default)One shared account per installbody.secrets.gmail_access_token / gmail_refresh_token, and body.connections[]
USEROne account per Caraer userbody.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_token
  • oauth.{provider}.{connectionId}.refresh_token
  • oauth.{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.

Installation secrets vault

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:

MethodPathReturns
GET/v2/apps/{appUuid}/installation/secretsSecret names only
PUT/v2/apps/{appUuid}/installation/secrets/{name}Body { "value": "…" }
DELETE/v2/apps/{appUuid}/installation/secrets/{name}
caraer apps secrets list

Cap 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):

MethodPathPurpose
GET / PUT/v2/apps/{appUuid}/installation/stateCursors, 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.


What not to do

  • 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.all when the installer picks the object. Use records.<setting_name>.all instead.
  • Request records.*.all (or the property/relation wildcards) unless the app truly needs every object in the tenant.
  • Skip validate before push.
  • Edit function code in the Caraer UI on a build-deployed app.
  • Commit .env or put raw clientSecret values in YAML. Use ${ENV_VAR}.
  • Treat authMethod: OAUTH2 as the way to connect Google. That is Caraer user consent. Third-party accounts are externalOAuthProviders.