How it works

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 and labels what is not built yet.

The mould is your config. The plan is the pour. The cast is your portal.
ir/1 → plan/1

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.

import, executed by the apptypes + codecs@kalup/corekalup.config.tskalup/objects/*.tskalup/removed.tsreaderparse, never runIRir/1engineclassify, orderplanplan/1executorwrite, read backwriteportalread-backstate.kalup/statelivenormalizedlist + normalizelive IRmerge3base from statewritercanonical formpullKALUPFIG. 5 · THE PATH · NOT TO SCALE

0.2In 0.1.0: the executor, the state file, kalup/removed.ts and the merge from a base.

target:production

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
// kalup.config.ts
import { defineConfig } from '@kalup/core'

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' } },
    },
  },
})
acme-crm · zsh
$ kalup plan --target production --json
{
  "format": "envelope/1",
  "ok": false,
  "issues": [
    {
      "code": "E_TARGET_PORTAL_MISMATCH",
      "message": "The key in HUBSPOT_PROD_READ_KEY belongs to portal 1111111, not portal 2222222 pinned for target production.",
      "configPath": "targets.production.portalId",
      "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. For a recreated test portal or sandbox, the user can run kalup target rebind production --portal <id> in a terminal; it refuses STANDARD accounts.",
      "humanRequired": true,
      "docs": "errors/E_TARGET_PORTAL_MISMATCH.md"
    }
  ]
}
# 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.

E_NOT_DATA

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.

Accepted: literals, builders, leading comments
kalup/objects/companies.ts
// 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(),
Rejected: spreads, loops, other calls
kalup/objects/companies.ts
const shared = { group: 'billing', fieldType: 'select' }

billingStatus: p.enum('billing_status', {
  ...shared,
  label: 'Billing status',
}),
E_NOT_DATA · kalup/objects/companies.ts:4 · companies.properties.billingStatus
expected a key but found '.'
fix: write key: value entries only; no spreads, computed keys or shorthand
  • 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.
.kalup/state/portal-2222222.json

Two truths and a small state file.

0.2 Config is the truth for what you intend. The portal is the truth for what exists. Between them, one file per portal records what Kalup last applied, so a plan can tell your change from someone else's.

  1. Safety never depends on state.A missing or stale base makes the plan hold and ask. It never overwrites.
  2. State describes the portal, not the code.It is gitignored and never lives on a working branch. In the CI recipe, a design not yet run in a real CI, it lives on a branch of its own, one per portal.
  3. Only apply moves it forward.The base advances only where config and portal agree. Held drift stays held across any number of applies.
  4. Nothing sensitive inside.The kalup.state/1 schema has no place for tokens, record data, fields Kalup does not own or resources it does not manage.
.kalup/state/portal-2222222.json, abridged
{
  "format": "kalup.state/1",
  "lastApply": { "actor": "--approve", "at": "2026-09-28T16:51:14.178Z", "outcome": "done", "planId": "pl_8a2d43f85238" },
  "lineage": "a70f763f50f8bf47",
  "portalId": 2222222,
  "resources": {
    "property:companies/billing_status": {
      "base": { "fieldType": "select", "label": "Billing status", "type": "enumeration" },
      "id": "billing_status",
      "normVersion": 1,
      "origin": "adopted"
    }
  },
  "serial": 5
}
class:*

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. Until there is a base, a field is converged or diverged.

BaseConfig vs baseLive vs baseClassDefaultWhat it means
anyconfig equals liveconvergednoneNothing to do.
yeschangedsameconfig-change0.2writeYou changed the file. Apply writes it.
yessamechangeddrift0.2holdSomeone changed the portal. The plan reports it and leaves it.
yeschangedchangedconflict0.2holdBoth sides changed. A person decides.
noneconfig differs from livedivergedholdNo 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.0.2

kalup plan --take config <address#field> writes your value and labels the step reverts-ui-edit, at risk risky.

A personal sandbox.0.2

A target can set drift: 'overwrite', and the default everywhere is hold. It acts where state holds a base: drift and conflicts are written, labelled reverts-ui-edit.

Before the first apply to a portal there is no base, so a difference on an existing resource shows as diverged, and it is held like the rest.

plan/1#steps

Anatomy of a plan step.

A plan is self-contained: apply reads the plan, the target in kalup.config.ts, credentials and state, never your object files. Each step carries everything a person needs to approve it.

~
Adopt property "Billing status" (billing_status) on companies, add options "Reseller"
property:companies/billing_status · public-api
safe
options[reseller]+ add { value: "reseller", label: "Reseller" }
labelheld · diverged
config "Billing status" · portal "Billing state"
expect: exists · options = every live option, as read
The plan headercounts: safe 2 · held 1writesHash: sha256:3f9a1c07b2e40b7eplanId: pl_3f9a1c07b2e4
address
The resource, as <type>:<path>. The same string in config, IR, plan and state.
action
create, adopt, update, delete, release or unknown, where a read could not tell. manual is kept for runbooks, which come later.
risk
safe, risky, destructive, blocked or manual. Risky needs a person or a reviewed CI job; destructive always needs the person.
transport
How the step reaches the portal: public-api today; public-beta and runbook are kept for later types.
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 what would change.The writesHash covers the destination (target and portal ID), the effective policy, the state lineage, the relevant bindings, and every step that creates, adopts, updates or deletes, with your desired values and the portal values it expects. Titles, counts, held values and times stay out, so an edited title or a new held value 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.

kalup apply

Apply writes the plan and nothing more.

0.2 For properties and property groups. Steps run one at a time, destructive steps last, each checked before and read back after. There is no rollback verb and no resume: recovery is a new plan.

  1. per step · 1 of 4re-checkexpect still holds
  2. per step · 2 of 4writeone step, serially
  3. per step · 3 of 4read backconfirm the result
  4. per step · 4 of 4advance baseonly where config and portal agree
  • A person for anything risky.A risky step needs someone at a real terminal typing the target name, or a reviewed CI job's --approve. A destructive step always needs the person, who also types the count.
  • Saved plans for protected targets.A protected target accepts only a saved plan file, never a plan made on the fly.
  • --yes has a ceiling.It covers an unprotected target with only safe steps, and at most 25 writes, adoptions and releases.
  • One shared budget.All requests share one rate limiter, and apply refuses when its calls would use more than half of the portal's daily calls left.
  • Partial is reported, not hidden.An apply that does 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.
ci:github

In CI, the property ships before the code.

Recipe The coordinated recipe, documented as a design and not yet run in a real CI. The pull request carries the production plan and its writesHash as a comment. After merge, one job per portal, holding the production write key, plans again and applies with the reviewed hash, then the app deploys.

  1. On the pull requestkalup plan --target production --out plan.json runs with the read key, and the job posts the plan and its writesHash as a comment.
  2. After mergeThe job plans again and runs kalup apply plan.json --approve with the reviewed hash. A plan that moved since the review is refused.
  3. Then the appThe deploy runs after apply, so the property exists before the code that reads it.
.github/workflows/kalup.yml (steps)
# The apply job after merge to main, abridged. A design, not yet run in a real CI.
concurrency: { group: kalup-portal-2222222, cancel-in-progress: false }
env: { KALUP_STATE_DIR: .kalup-state/state }
steps:
  - run: git fetch origin kalup-state/portal-2222222 && git worktree add -B kalup-state/portal-2222222 .kalup-state FETCH_HEAD
  - run: npx kalup plan --target production --out plan.json
  - run: npx kalup apply plan.json --approve "$REVIEWED_HASH"   # the writesHash posted on the pull request
  - if: always()
    run: cd .kalup-state && git add state && git commit -m "Apply $GITHUB_SHA" && git push origin HEAD:kalup-state/portal-2222222

In the recipe, state lives on a branch per portal, kalup-state/portal-2222222, checked out as a worktree that KALUP_STATE_DIR names. One job per portal writes at a time, in a concurrency group that never cancels a run in progress. A rejected push after apply is a recovery incident, never a lock. Deletes never run in CI: --approve never covers one. The Sandbox, production and CI guide has the full workflow.

schema:ir/1 · schema:plan/1

Two JSON contracts. Build on either.

Both documents have a JSON Schema in the repository and change only by adding fields inside a version. kalup docs reads the IR today, and any tool you write can read either. None of them needs the TypeScript.

ir/1 · what your files mean
kalup ir, abridged
{
  "irVersion": 1,
  "project": "acme-crm",
  "resources": {
    "property:companies/billing_status": {
      "binding": { "codec": "enum", "key": "billingStatus", "required": true },
      "definition": {
        "fieldType": "select",
        "group": { "$ref": "group:companies/billing" },
        "label": "Billing status",
        "type": "enumeration"
      },
      "managed": true,
      "type": "property"
    }
  },
  "targets": {
    "production": { "portalId": 2222222, "protected": true },
    "sandbox": { "portalId": 1111111 }
  }
}
plan/1 · what apply would do
plan.json from kalup plan --out, abridged
{
  "format": "plan/1",
  "planId": "pl_3f9a1c07b2e4",
  "target": { "name": "production", "portalId": 2222222, "protected": true, "drift": "hold" },
  "counts": { "safe": 2, "risky": 0, "destructive": 0, "blocked": 0, "manual": 0, "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 tokens and no transport names. Its targets carry each portal ID; its resources hold none. The plan holds no tokens either. Every resource has one address, the same in config, IR, plans and state.