From a file to a portal, one reviewed step at a time.
Kalup reads your files and your portal, works out the difference, and writes a plan in HubSpot's own words. Apply writes that plan and nothing else. This page follows the path end to end.
The whole path, on one sheet.
Two versioned JSON documents hold the system together: the IR says what your files mean, the plan says what apply would do to one target. Everything else reads one of the two.
A target is a portal with a name and a pin.
Each target in your config names a portal and pins it to a portal ID. Kalup refuses to read, plan or apply when the key belongs to a different portal, so a sandbox key can never touch production by mistake.
// kalup.config.ts
import { defineConfig } from 'kalup'
export default defineConfig({
name: 'acme-crm',
objects: {
companies: { include: ['name', 'domain'] },
subscription: {},
},
targets: {
sandbox: { portalId: 1111111, credentials: { read: { env: 'HUBSPOT_SANDBOX_KEY' } } },
production: {
portalId: 2222222,
protected: true,
drift: 'hold',
credentials: { read: { env: 'HUBSPOT_PROD_READ_KEY' }, write: { env: 'HUBSPOT_PROD_WRITE_KEY' } },
},
},
})$ kalup status --target production --json { "format": "envelope/1", "ok": false, "issues": [{ "code": "E_TARGET_PORTAL_MISMATCH", "fix": "The key in HUBSPOT_PROD_READ_KEY belongs to portal 1111111. Ask the user to check the key and the pinned portalId for target production." }] } exit 4 · a person is needed $
The fix never says "change the pin". Fix hints never tell an agent to switch off a safety check.
Your files look like TypeScript. Kalup reads them as data.
The app runs the object files for types and codecs. Kalup never does. It parses a small grammar and prints it back in one canonical form, which is why an agent can edit it safely and pull can write it back without losing your comments.
// Set by the billing sync. Do not edit by hand.
billingStatus: p.enum('billing_status', {
label: 'Billing status',
group: 'billing',
fieldType: 'select',
options: [
{ value: 'active', label: 'Active' },
{ value: 'PAST DUE', label: 'Past due', as: 'past_due' },
],
}).required(),const shared = { group: 'billing', fieldType: 'select' }
billingStatus: p.enum('billing_status', {
...shared,
label: 'Billing status',
}),Spread is not allowed in a property definition.
fix: Write the shared fields out in full. Loops and spreads are not part of the config grammar.
- One canonical form.Properties sorted by internal name, options in display order, quotes and line breaks as biome would write them.
- Round trips, tested.Writing what was parsed gives back the same text, and a second pull with no portal change is byte-identical.
- Nothing runs.A config file cannot run code in the tool, because the tool never executes it.
Two truths and a small state file.
Config is the truth for what you intend. The portal is the truth for what exists. Between them, one file per target records what Kalup last applied, so a plan can tell your change from someone else's.
- Safety never depends on state.A missing or stale base makes the plan hold and ask. It never overwrites.
- State describes the portal, not the code.It is gitignored and never lives on a working branch. In CI it lives on its own branch.
- Only apply moves it forward.The base advances only where config and portal agree. Held drift stays held across any number of applies.
- Nothing sensitive inside.No tokens, no record data, no fields Kalup does not own, no resources it does not manage.
{
"format": "kalup.state/1",
"lineage": "b0a1c6e2",
"serial": 42,
"target": { "name": "production", "portalId": 2222222 },
"resources": {
"property:companies/billing_status": {
"origin": "adopted",
"id": "billing_status",
"via": "public-api",
"base": { "label": "Billing status" }
}
}
}Five classes. Three of them hold.
Every owned field is compared three ways: config against the last applied base, the portal against the same base. The answer is one of five classes, and the default for anything someone else touched is to hold.
| Base | Config vs base | Live vs base | Class | Default | What it means |
|---|---|---|---|---|---|
| any | config equals live | converged | none | Nothing to do. | |
| yes | changed | same | config-change | write | You changed the file. The plan writes it. |
| yes | same | changed | drift | hold | Someone changed the portal. The plan reports it and leaves it. |
| yes | changed | changed | conflict | hold | Both sides changed. A person decides. |
| none | config differs from live | diverged | hold | No record of the last apply, so no way to tell who changed what. | |
Take the portal's side.kalup pull brings the portal's values into your files.
Take yours.kalup plan --take config <address#field> writes your value and labels the step reverts-ui-edit.
A personal sandbox.A target can opt into drift: 'overwrite'. The default everywhere is hold.
With no state yet, for example the first plan against a portal you just pulled, there is no base. A difference on an existing resource then shows as diverged, and it is held like the rest.
Anatomy of a plan step.
A plan is self-contained: apply needs the plan, credentials and state, never your files. Each step carries everything a person needs to approve it.
config "Billing status" · portal "Billing state"
- address
- The resource, as <type>:<path>. The same string in config, IR, state and plan.
- action
- create, adopt, update, delete or manual.
- risk
- safe, risky, destructive, blocked or manual. Risky and destructive need a person.
- transport
- How the step reaches the portal: public-api, public-beta or runbook.
- title
- HubSpot's own UI wording, from a fixed template.
- changes
- Each unit with its value before and after.
- held
- Fields the plan will not write, with the class that held them.
- expect
- What the portal must still look like. Apply re-checks it right before the write.
Approval binds to the writes.The writesHash covers every create, update and delete: the address, the action, your values and the portal values it expects. Held lines, counts, titles and times stay out, so a busy portal does not void an approval and an edited title changes nothing.
It says what it cannot do.Plans print, once per type, what a step cannot copy because HubSpot has no API for it. A plan that stays silent about that would mislead more than a wrong label.
Apply writes the plan and nothing more.
Steps run one at a time, destructive steps last. Each is checked before and read back after. There is no rollback verb and no resume: recovery is a new plan.
- per step · 1 of 4re-checkexpect still holds
- per step · 2 of 4writeone step, serially
- per step · 3 of 4read backconfirm the result
- per step · 4 of 4advance baseonly where config and portal agree
- A person for anything risky.Risky and destructive steps need someone at a real terminal typing the target name and the destructive count.
- Saved plans for protected targets.A protected target only accepts a plan file that was reviewed, never a plan made on the fly.
- --yes has a ceiling.It refuses any plan with more than 25 writes.
- One shared budget.All writes share one rate limiter, and apply refuses when the estimate exceeds the portal's daily headroom.
- Partial is reported, not hidden.An apply that did not finish exits 5. Run plan again to see where things stand.
- Deletes need four keys.A tombstone, ownership in this target, a policy that allows it, and a person at a terminal.
In CI, the property ships before the code.
The pull request carries the production plan as a comment. After merge, a job that holds the production write key plans again, applies the saved plan, then deploys the app.
- On the pull requestkalup plan --target production --json runs and the plan is posted as a comment.
- After mergeThe job plans again, saves the plan and applies it with the production write key.
- Then the appThe deploy runs after apply, so the property exists before the code that reads it.
- run: git fetch origin kalup-state:kalup-state && git worktree add .kalup/state kalup-state
- run: npx kalup plan --target production --out plan.json
- run: npx kalup apply plan.json
- if: always()
run: cd .kalup/state && git add -A && (git diff --cached --quiet || (git commit -m "apply $GITHUB_SHA" && git push origin kalup-state))State for CI lives on a kalup-state branch, checked out as a worktree at .kalup/state. A rejected push means another apply ran at the same time.
Two JSON contracts. Build on either.
Both documents have a published JSON Schema and change only by adding fields inside a version. The docs generator, the typed client and your own tools read them. None of them needs the TypeScript.
{
"irVersion": 1,
"project": "acme-crm",
"resources": {
"property:companies/billing_status": {
"type": "property",
"managed": true,
"definition": {
"label": "Billing status",
"group": { "$ref": "group:companies/billing" },
"type": "enumeration",
"fieldType": "select"
},
"binding": { "key": "billingStatus", "codec": "enum", "required": true }
}
}
}{
"format": "plan/1",
"planId": "pl_3f9a1c07b2e4",
"target": { "name": "production", "portalId": 2222222, "protected": true },
"counts": { "safe": 2, "manual": 1, "held": 1 },
"writesHash": "sha256:3f9a1c07b2e40b7e",
"steps": [
{
"address": "property:companies/renewal_date",
"action": "create",
"risk": "safe",
"transport": "public-api",
"expect": { "exists": false }
}
]
}The IR holds no portal IDs, no tokens and no transport names. The plan holds no tokens either. Every resource has one address, and it is the same in config, IR, state and plans.