SalesOS

Metadata as code

Keep objects, fields, rules, workflows, actions, hooks, functions, pages, flows, profiles, reports and more in source control, test them, and deploy them from a developer org to production with the CLI or the metadata API.

Everything you configure in Settings can live in a git repository as plain JSON (and TypeScript for functions). Pull it from an org, change it, review it, and deploy it to another org — the same way you ship code.

salesos project init my-crm && cd my-crm
salesos login -p devorg --email [email protected]      # your developer org
salesos login -p production --api-key                 # a key with MANAGE_METADATA
salesos metadata pull -p devorg
git add . && git commit -m "Invoices object and approval rules"
salesos metadata diff -p devorg --target production
salesos test --target production                      # run the package's tests, write nothing
salesos metadata deploy -p devorg --target production --check-only
salesos metadata deploy -p devorg --target production

The source format

A project is a directory with salesos.project.json and one file per component under its sourceDir (default src). Format version 1.

salesos.project.json
{
  "$schema": "https://docs.salesos.org/schemas/salesos.metadata.schema.json",
  "formatVersion": 1,
  "name": "my-crm",
  "sourceDir": "src"
}
ComponentPath (under sourceDir)Matched on
Custom objectobjects/<name>.object.jsonname
Custom fieldfields/<ENTITY>.<name>.field.jsonentity + name
Validation rulevalidation-rules/<ENTITY>.<name>.rule.jsonentityType + name
Workflowworkflows/<name>.workflow.jsonname
Custom actionactions/<apiName>.action.jsonapiName
Before-save hookhooks/<OBJECT>.<name>.hook.jsonobjectType + name
Functionfunctions/<apiName>/function.json + index.ts or index.jsapiName (the folder)
Page layoutlayouts/<OBJECT>.<profile or default>.layout.jsonobjectType + profile name
Flowflows/<apiName>.flow.json (the active version's definition)apiName
Approval processapproval-processes/<ENTITY>.<name>.approval.jsonentity + name
Custom labellabels/<name>.label.jsonname
Translationstranslations/<locale>.translation.jsonlocale
Record pagepages/<apiName>.page.jsonapiName
App pageapp-pages/<apiName>.apppage.jsonapiName
Profileprofiles/<name>.profile.json (module permissions incl. custom:<object>, field-level security)name
Reportreports/<name>.report.json (definition only; no schedule or recipients)name
Dashboarddashboards/<name>.dashboard.json (team and org dashboards)name
Email templateemail-templates/<name>.email.json (shared templates)name
List viewlist-views/<ENTITY>.<name>.view.json (ORG and TEAM views)entity + name
Assignment ruleassignment-rules/<ENTITY>.<name>.assignment.jsonentity + name
Named credentialcredentials/<apiName>.credential.json (configuration only, never secrets)apiName
Custom event typeevents/<apiName>.event.jsonapiName
After-save triggerafter-save/<OBJECT>.<name>.trigger.json (names its function by apiName)objectType + name
Testsfunctions/<apiName>/tests/*.test.json, tests/{flows,validation-rules,formulas}/*.test.jsonpath (Testing)

There is no permission-set type: SalesOS grants access through profiles, so a profile file is where module permissions (including each custom object's custom:<apiName> module) and field-level security travel.

The key a component is matched on comes from the file's contents, not its name, so you can rename files freely; pull writes canonical names. Files are JSON with identity keys first and the rest sorted, so diffs stay small. Unknown properties are rejected (a typo should fail, not vanish).

The JSON Schema is published at /schemas/salesos.metadata.schema.json; $defs holds one definition per file type (customObject, customField, validationRule, workflow, customAction, beforeSaveHook, function, pageLayout, page, appPage, flow, approvalProcess, customLabel, translation, profile, report, dashboard, emailTemplate, listView, assignmentRule, namedCredential, customEventType, afterSaveTrigger, and the test files functionTest, flowTest, validationRuleTest, formulaTest). Map them in your editor, e.g. VS Code json.schemas with "fileMatch": ["src/actions/*.action.json"] and "url": "…/salesos.metadata.schema.json#/$defs/customAction". The server checks the same rules on every deploy.

src/functions/score_lead/function.json
{
  "apiName": "score_lead",
  "name": "Score lead",
  "eventTypes": ["lead.created"],
  "language": "typescript",
  "scopes": ["READ_LEADS", "WRITE_LEADS"],
  "secrets": ["ENRICH_TOKEN"],
  "timeoutMs": 10000
}

What never travels

  • Records. Only definitions move.
  • Secrets. Custom actions and hooks get a new signing secret in the target (rotate it in Settings → Developer to read it). Named credentials carry their configuration only; a new one that needs a secret arrives inactive and the deploy result names the secret to set. Functions list their secret names in secrets; a deploy warns missing secrets: … for names the target has not set — set them with salesos functions secrets <apiName> set NAME.
  • What belongs to a person. Private dashboards, private list views and private email templates are never retrieved; report schedules and recipients stay behind. New reports, dashboards and views belong to the deployer.
  • People and ids. A new function runs as the member who deploys it. Assignment rules name assignees and the fallback owner by email, TEAM views name their team, approval steps name a user's email or a profile. Page layouts name their profile ("profile": "Sales Rep", null for the org default) and app slots as app/<appSlug>:<key>, which resolve to the target's profile and installation.
  • Components an installed app manages are not retrieved, and a deploy that touches one fails — change them through the app.

Deploy semantics

A deploy is an async job:

  1. Parse. A malformed package is a 400 METADATA_SOURCE_INVALID whose errors[] name each file. Nothing is queued.
  2. Validate against the target as it is now: shapes and enums, references (a workflow's CUSTOM_ACTION and FUNCTION steps, an action's function, a hook's custom:<object>, a layout's fields, profile and app slots — each must be in the package or the target), endpoint URLs (the same checks as live calls), function compilation, plan features (functions need Serverless Functions, layouts Apps & Page Layouts) and limits. Each component is planned CREATE, UPDATE, UNCHANGED or DELETE.
  3. Test. Unless runTests is NONE, the package's tests run (and with ALL, the tests earlier deploys left in the org) — on check-only too. A failing test fails the deploy and nothing is written; see Testing.
  4. With checkOnly: true, stop: the result says what would happen, nothing is written.
  5. Apply everything that changed in one transaction, in dependency order (labels → objects → fields → profiles → named credentials → custom event types → functions → email templates → actions → hooks → after-save triggers → rules → flows → workflows → assignment rules → approval processes → layouts → pages → app pages → reports → dashboards → list views → translations), then the destructive list in reverse order. Any error rolls back the whole deploy (rolled_back on every component).

Mirroring change sets: new custom actions, hooks, after-save triggers and functions arrive inactive unless the deploy sets activate: true (a function with declared secrets stays inactive until they are set); new workflows arrive as drafts and new assignment rules inactive; updates keep the target's on/off state and secrets. A changed function source becomes a new version and is deployed.

Destructive changes are explicit: destructive: [{ type, key }] (--delete-missing computes "in the org but not in my project", and asks for --yes). Deleting something still in use fails validation — an action a workflow calls, a function an action runs, an object that has records, a profile people are still assigned to.

API

Org owner/admin (or a system admin), with a session, an API key or an OAuth token carrying MANAGE_METADATA. The scope exists so CI can deploy headlessly; the key acts as its owner, who must still be an org owner or admin.

Endpoint
GET /v1/metadata/typesTypes, paths, keys, limits
POST /v1/metadata/retrieve { types?, components?, projectName? }{ project, sourceDir, files: { path: text }, components }
POST /v1/metadata/deploy { files, project?, checkOnly?, activate?, destructive?, label?, runTests? }202 with the queued deploy
GET /v1/metadata/deploysHistory, newest first (page, limit, status)
GET /v1/metadata/deploys/:idStatus, progress (phase, done, total), results[] per component and testResults
GET / PUT /v1/metadata/test-policy { requireTestsFor }The org's test policy (PUT needs a signed-in person)
POST /v1/metadata/deploys/:id/checkRe-run the stored package as check-only
POST /v1/metadata/deploys/:id/cancelCancel a queued deploy

Limits: 600 files, 5 MB per package (512 KB per file), 200 destructive changes, 3 queued or running deploys per org. Idempotency-Key works on deploy. Packages are kept 30 days (for re-runs), history 365 days. Every retrieve, deploy and result is in the admin audit log with who, how (session, key or app) and what.

Settings → Developer → Deployments shows the history with each component's result and problems, and re-runs any deploy as check-only.

Change sets

Developer org change sets use the same engine, so they carry every type above (tests travel with the metadata API).

On this page