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.
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.
| Component | Path (under sourceDir) | Matched on |
|---|---|---|
| Custom object | objects/<name>.object.json | name |
| Custom field | fields/<ENTITY>.<name>.field.json | entity + name |
| Validation rule | validation-rules/<ENTITY>.<name>.rule.json | entityType + name |
| Workflow | workflows/<name>.workflow.json | name |
| Custom action | actions/<apiName>.action.json | apiName |
| Before-save hook | hooks/<OBJECT>.<name>.hook.json | objectType + name |
| Function | functions/<apiName>/function.json + index.ts or index.js | apiName (the folder) |
| Page layout | layouts/<OBJECT>.<profile or default>.layout.json | objectType + profile name |
| Flow | flows/<apiName>.flow.json (the active version's definition) | apiName |
| Approval process | approval-processes/<ENTITY>.<name>.approval.json | entity + name |
| Custom label | labels/<name>.label.json | name |
| Translations | translations/<locale>.translation.json | locale |
| Record page | pages/<apiName>.page.json | apiName |
| App page | app-pages/<apiName>.apppage.json | apiName |
| Profile | profiles/<name>.profile.json (module permissions incl. custom:<object>, field-level security) | name |
| Report | reports/<name>.report.json (definition only; no schedule or recipients) | name |
| Dashboard | dashboards/<name>.dashboard.json (team and org dashboards) | name |
| Email template | email-templates/<name>.email.json (shared templates) | name |
| List view | list-views/<ENTITY>.<name>.view.json (ORG and TEAM views) | entity + name |
| Assignment rule | assignment-rules/<ENTITY>.<name>.assignment.json | entity + name |
| Named credential | credentials/<apiName>.credential.json (configuration only, never secrets) | apiName |
| Custom event type | events/<apiName>.event.json | apiName |
| After-save trigger | after-save/<OBJECT>.<name>.trigger.json (names its function by apiName) | objectType + name |
| Tests | functions/<apiName>/tests/*.test.json, tests/{flows,validation-rules,formulas}/*.test.json | path (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.
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 warnsmissing secrets: …for names the target has not set — set them withsalesos 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",nullfor the org default) and app slots asapp/<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:
- Parse. A malformed package is a
400 METADATA_SOURCE_INVALIDwhoseerrors[]name each file. Nothing is queued. - Validate against the target as it is now: shapes and enums, references (a workflow's
CUSTOM_ACTIONandFUNCTIONsteps, an action'sfunction, a hook'scustom:<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 plannedCREATE,UPDATE,UNCHANGEDorDELETE. - Test. Unless
runTestsisNONE, the package's tests run (and withALL, the tests earlier deploys left in the org) — on check-only too. A failing test fails the deploy and nothing is written; see Testing. - With
checkOnly: true, stop: the result says what would happen, nothing is written. - 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_backon 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/types | Types, 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/deploys | History, newest first (page, limit, status) |
GET /v1/metadata/deploys/:id | Status, 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/check | Re-run the stored package as check-only |
POST /v1/metadata/deploys/:id/cancel | Cancel 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).
CLI
salesos — sign in, call any endpoint, deploy and test metadata, listen to live events, work with functions, records, bulk jobs and developer orgs from the terminal.
Testing metadata
Test functions, flows, validation rules and formulas in your metadata package. Tests run on every check-only and real deploy, with every outside call mocked, and a failing test stops the deploy.