Your HubSpot portal, in a pull request.
Describe properties and objects in TypeScript. Kalup reads any portal you name, shows every change as a plan, and applies the plan you approve to properties and property groups. Open source, on npm, and free on your machine and in your CI.
npm install @kalup/core && npm install -D kalup && npx kalup init --portal <portal id>kalup/ˈka.lup/ · noun · SlovenianA mould: the form you pour metal into. Your config is the mould. Kalup pours your portal into it.
Where Kalup sits.
Between the files in your repository and your HubSpot portals. Every change goes through a plan you can read before anything is written.
defineObject('companies', {name: p.string(...),billingStatus: p.enum(...),})
written by kalup pull
- Name
- Billing status
- Name
- Billing status
protected · apply needs a person at a terminal or reviewed CI
No file.The config lives in the portal and nowhere else.
No diff.A change lands the moment someone clicks save.
No history.The audit log API needs Enterprise. Below that, history is what people remember.
And now AI agents can change your portal from a prompt. A prompt is not a review.
Read the plan, not the config.
Every step names the resource, the risk and the exact values before and after. Titles use the words of the HubSpot UI, so an admin can approve a change without reading TypeScript.
config "Billing status" · portal "Billing state"
Each step also records what it expects to find. Apply re-checks that right before each write, and stops if the portal changed since you approved.
$ kalup plan --target sandbox Plan pl_6fcf740d2b33 for target sandbox, portal 1111111 (SANDBOX, not protected) Settings: mode addon; adopt hold; drift hold; allowDestroy false; yesLimit 25 s1 safe Adopt property group "Billing" (billing) on companies s2 safe Adopt property "Billing status" (billing_status) on companies, add option "Trial" + option "Trial" ("trial") held label drift: config "Billing status", portal "Billing state", last agreed "Billing status". Take the portal side: kalup pull --target sandbox --only property:companies/billing_status; take config: kalup plan --target sandbox --take config 'property:companies/billing_status#label' s3 safe Create property "Churn reason" (churn_reason) on companies label "Churn reason", group billing, fieldType "text" s4 safe Adopt property "Seats" (seat_count) on companies, set label label: "Seat count" -> "Seats" 4 safe, 0 risky, 0 destructive, 0 blocked, 0 manual; 1 held Coverage: complete; 0 unsupported, 0 skipped. Not copied, HubSpot has no API: conditional property logic, field-level permissions. $
Nothing changes behind your back.
People keep editing the portal in the HubSpot UI, and that is fine. Kalup holds those edits instead of overwriting them, and deleting a line from a file deletes nothing.
Edits in the UI are held.
billingStatus: p.enum('billing_status', {
label: 'Billing status',
...
})config "Billing status" · portal "Billing state" · not written
A delete needs four keys.
0.2- key 1Tombstonekalup rm writes it to hubspot/removed.ts
- key 2Ownershipstate shows Kalup created or adopted it in this portal
- key 3Policythe target sets allowDestroy: true
- key 4A personat a terminal, typing the count
All four keys. Only then can a delete run.
Let the agent do the typing. Keep the approval.
Kalup is built to be driven by Claude Code and other agents. When a person is needed, it stops and says so.
- Speaks JSON.Every command takes --json, with fixed exit codes and errors that name the file, line and fix.
- Writes its own rules.kalup init writes an AGENTS.md: edit config and plan, never write to the portal directly.
- Stops at the wrong portal.A key that belongs to another portal stops any command that reads the portal, with exit 4. The fix tells the agent to ask you, never to change the pin.
- Stops at production.0.2Applying to a protected target needs a person at a terminal typing its name, or a reviewed CI job with --approve and a write key only that job holds. Deletes always need the person. An agent cannot say yes for you.
- Reads, never obeys.Text read from the portal is data, never instructions.
$ 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 file that shapes the portal also types your app.
Import your object files and get exact types and codecs. No generate step, no hand-typed property names drifting away from the portal.
// kalup/objects/companies.ts
import { defineObject, p, type InferProperties } from '@kalup/core'
export const Company = defineObject('companies', {
groups: { billing: { label: 'Billing' } },
properties: {
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(),
name: p.string('name'),
renewalDate: p.date('renewal_date', {
label: 'Renewal date',
group: 'billing',
fieldType: 'date',
}),
},
})
export type CompanyData = InferProperties<typeof Company.properties> & { id: string }company.billingStatus'PAST DUE' in the portal is 'past_due' in your code. The codec translates both ways.
Three ways in.
Same files, same plan, three daily flows. Move your cursor over the metal.
Developers
/developersShip the property before the code that needs it. The same files type your app, a plan shows the portal change, and apply writes it to your sandbox.
Read the flow →Agents
/agentsFor admins working through Claude Code. Ask for the change, read the plan, then apply it yourself at a terminal.
Read the flow →Agencies
/agenciesEvery client portal pulled into files, documented, and set up from the same blueprint.
Read the flow →When HubSpot has no API, Kalup says so.
Some settings can only be changed in the UI. Today the plan names them, once per type, so nobody assumes they were copied. Later, it will print a runbook with the page, the fields and the values for a person to follow. Kalup never claims a change it could not make.
See full coverage- 1. Open the settings for the deal pipeline "Enterprise".
- 2. Edit the stage "Closed won".
- 3. Make "Renewal date" and "Billing status" required on that stage.
- verify: both properties show as required on the stage
Free, and it stays free.
Apache-2.0. Everything that runs on your machine or in your CI against HubSpot's public APIs is free and stays free. The licence will not tighten.
- 0.2Pull, plan, apply, drift, blueprints and every property field0.2
- PipelinesPipelines and stagesNext
- Schemas and labelsCustom object writes, association labelsNext
- CloudShared execution for agency teamsLater