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.

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

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

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
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.
.kalup/state/production.json

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.

  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 CI it lives on its own branch.
  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.No tokens, no record data, no fields Kalup does not own, no resources it does not manage.
.kalup/state/production.json
{
  "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" }
    }
  }
}
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.

BaseConfig vs baseLive vs baseClassDefaultWhat it means
anyconfig equals liveconvergednoneNothing to do.
yeschangedsameconfig-changewriteYou changed the file. The plan writes it.
yessamechangeddriftholdSomeone changed the portal. The plan reports it and leaves it.
yeschangedchangedconflictholdBoth 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.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.

plan/1#steps

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.

~
Add option "Reseller" to company property "Billing status"
property:companies/billing_status · public-api
safe
options[reseller]+ add { value: "reseller", label: "Reseller" }
labelheld · drift
config "Billing status" · portal "Billing state"
expect: exists · label = "Billing state" · options[reseller] = null
The plan headercounts: safe 2 · manual 1 · held 1writesHash: sha256:3f9a1c07b2e40b7eplanId: pl_3f9a1c07b2e4
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.

kalup apply

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.

  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.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.
ci:github

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.

  1. On the pull requestkalup plan --target production --json runs and the plan is posted as a comment.
  2. After mergeThe job plans again, saves the plan and applies it with the production write key.
  3. Then the appThe deploy runs after apply, so the property exists before the code that reads it.
.github/workflows/kalup.yml (steps)
- 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.

schema:ir/1 · schema:plan/1

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.

ir/1 · what your files mean
kalup ir --json
{
  "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 }
    }
  }
}
plan/1 · what apply would do
kalup plan --target production --out plan.json
{
  "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.