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

## What you need

- 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](mailto: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.

|  | 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*`.

## 1. Install the CLI


```bash
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:


```bash
caraer skill install
```

## 2. Sign in and select a company


```bash
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


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

For a company-only app:


```bash
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](#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


```text
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
# 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](https://github.com/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](#scopes-macros-and-settings).
- Group fields with `settingsSections`. Caraer lays cards out automatically
(max three across). Hide advanced fields with `visibleWhen`.



```bash
caraer apps add setting inbox_label --type SINGLE_LINE --label "Inbox label"
```

## 6. Write a function

Node (default):


```js
exports.handler = async (req, res) => {
  const body = req.body || {};
  return res.status(200).json({ ok: true, event: body.event || null });
};
```

Python:


```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:


```json
{
  "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`](https://www.npmjs.com/package/@caraer/client) | `npm install @caraer/client` | [Caraer-HQ/caraer-js-client](https://github.com/Caraer-HQ/caraer-js-client) |
| Python | [`caraer-client`](https://pypi.org/project/caraer-client/) | `pip install caraer-client` | [Caraer-HQ/caraer-python-client](https://github.com/Caraer-HQ/caraer-python-client) |


API reference: [developer.caraer.com](https://developer.caraer.com). OpenAPI:
[v2.api.caraer.com/api-docs.yaml](https://v2.api.caraer.com/api-docs.yaml).


```ts
import type { WebhookPayload } from "@caraer/client";
```


```python
from caraer_client import WebhookPayload
```

Put shared code in `src/app/shared/` and import it with the same relative path
locally and in production:


```js
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:


```bash
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.

| 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](/blog/2026-08-23-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`):


```http
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:


```json
{
  "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:


```bash
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


```bash
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


```bash
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:


```bash
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:


```bash
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).

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

### 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:


```yaml
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:



```yaml
- 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).


## 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`.

### Declare a provider


```yaml
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:


```http
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:


```http
GET    {caraerApiBase}/v2/apps/{appUuid}/installation/connections
DELETE {caraerApiBase}/v2/apps/{appUuid}/installation/connections/{providerOrConnectionId}
```

`body.connections[]` looks like:


```json
{
  "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:

| 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}` | — |



```bash
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):

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

## 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`.


## Related reading

- [Serverless functions in Caraer apps](/blog/2026-03-25-serverless-functions-in-caraer-apps)
- [App bars in Caraer apps](/blog/2026-08-23-app-bars-in-caraer-apps)
- [Caraer CLI](/apis/cli)
- [Caraer data model: Object, Property, Relation](/blog/2026-03-25-caraer-data-model-object-property-relation)
- Node SDK: [@caraer/client](https://www.npmjs.com/package/@caraer/client) ([source](https://github.com/Caraer-HQ/caraer-js-client))
- Python SDK: [caraer-client](https://pypi.org/project/caraer-client/) ([source](https://github.com/Caraer-HQ/caraer-python-client))