Getting started
Install Kalup from npm, pull a portal into files, make a first change and apply it
This page takes you from an empty directory to a committed set of config files and a first change applied. Nothing is written to the portal until the last step, and only after you confirm it.
Before you start
- Node 22.13.1 or later.
- The portal's Hub ID, from the account menu in HubSpot. This page uses
1111111. - A Super Admin to create the service key: a key can only hold scopes its creator has. Start on a developer test account or sandbox if you have one.
Install
In your project directory (run npm init -y && npm pkg set type=module first if it has no package.json):
npm install @kalup/core
npm install -D kalupWith pnpm, yarn or bun: pnpm add @kalup/core && pnpm add -D kalup, and the same with yarn add or bun add.
@kalup/core is what the config files and your app import: builders, codecs and types, with no network and no file system. Your app imports it at run time (7 kB, no dependencies), so it is a regular dependency. kalup is the CLI, a dev tool. If you skip the first line, kalup init adds @kalup/core to package.json for you.
The config files are ES modules, so the project should be "type": "module". A CommonJS project with verbatimModuleSyntax cannot compile them.
Create a service key
In HubSpot, open Development > Keys > Service keys (HubSpot's guide) and create a key with, for each object you manage (contacts, companies and deals by default):
crm.schemas.<object>.read, to read its properties and groups. A custom object needscrm.schemas.custom.read.crm.schemas.<object>.write, to apply changes. Leave it out for objects you only read.- One
crm.objects.<object>.read, such ascrm.objects.contacts.read, soplancan read HubSpot's property limit. Kalup never reads records with it.
init prints the exact list for the objects you pick, and status checks the read scopes. One admin, one portal explains each scope.
Put the key in .env in the project directory, or in the environment:
HUBSPOT_SERVICE_KEY=<your key>Kalup reads the variable a target names in credentials.read.env, or HUBSPOT_SERVICE_KEY when it names none. It never prints a key, not even in an error.
Create the project
npx kalup init --portal 1111111init needs no key and sends nothing to HubSpot. It writes:
kalup.config.tswith one target namedproduction, pinned to portal1111111.--target <name>picks another name; a name gives the target no role. Without--portalthe target is pending until you setportalId.hubspot/index.ts, the barrel your app imports, empty until the first pull.--dir lib/config/hubspotputs the folder elsewhere.- The lines
.kalup/and.envin.gitignore, created when missing, so state, history and the key stay out of git. In a monorepoinitedits the.gitignoreand formatter config it finds up to the repository root instead. - A formatter ignore for
hubspot/andkalup.config.ts:!hubspot,!kalup.config.tsand!.kalupadded tofiles.includesinbiome.jsonorbiome.jsoncwhen one exists, with the file's comments kept, else the lineshubspot/andkalup.config.tsin.prettierignorewhen a Prettier config exists, else a printed note. The writer's format is the only format for those files. AGENTS.mdwith the rules an AI agent follows in this project, appended when the file exists.CLAUDE.mdwith the one line@AGENTS.md, so Claude Code reads the same rules. It is created when missing and appended when it exists, unless it already points atAGENTS.md.@kalup/coreindependenciesinpackage.json, when no dependency list has it. Run your package manager's install afterwards;initdoes not install it.
--objects contacts,companies,deals is the default scope. A custom object goes in by its name, for example --objects companies,subscription. The output lists the target and its objects, the files written, the read scopes the key needs, one per object, with the link to the service key settings, one recommended crm.objects.<object>.read scope so plan can check HubSpot's property limit (it also lets the key read that object's records, which Kalup never requests), and what is left to do.
init refuses to run when kalup.config.ts already exists. Run pull instead.
Pull the portal
npx kalup pullpull reads the account behind the key first. When that account is not portal 1111111, it stops with exit 4 and writes nothing. Otherwise it writes one file under hubspot/objects/ for each object that has custom properties, and records in .kalup/state/ what the files and the portal agree on; the first time, it prints the path. A pulled company file:
import { defineObject, type InferProperties, p } 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' },
],
}),
renewalDate: p.date('renewal_date', {
label: 'Renewal date',
group: 'billing',
fieldType: 'date',
}),
},
})
export type CompanyData = InferProperties<typeof Company.properties> & { id: string }Each property gets a camelCase key, the builder that matches its HubSpot type, and its full definition when the portal lets you own it. HubSpot-defined properties such as name come in when include in kalup.config.ts lists them, for example companies: { include: ['name', 'domain'] } under objects, or when you add one to a file yourself: every property a file defines is in scope. They come in as references, such as name: p.string('name'): no definition, never created, changed or removed. Config files explains the grammar, Property builders each builder, and kalup pull the scope, the merge rules and the flags.
When the portal changes
Run pull again whenever someone changes the portal in the HubSpot UI, or after you change the scope in kalup.config.ts, so the files catch up:
npx kalup pullWith one target, no command needs --target. A pull with no change in the portal writes nothing. Before a file is overwritten, the old one is copied to .kalup/history/.
Validate
npx kalup validatevalidate runs offline. It checks kalup.config.ts and every file under hubspot/ and reports each problem with the file, the line and a fix. Exit 0 means valid, 3 means invalid. Every other command runs the same checks first, so an invalid project stops before anything is read or written.
Add --json to any command for one envelope/1 document on stdout and nothing else:
{ "format": "envelope/1", "ok": false, "issues": [{ "code": "E_UNKNOWN_GROUP", "message": "...", "file": "hubspot/objects/companies.ts", "line": 12, "fix": "..." }] }That is what a script or an AI agent reads. issues[].fix says what to do.
Make a first change
The properties you pulled already exist in the portal, so the first plan adopts them: Kalup records that it manages them, and writes nothing else. Change something in the file first, for example a label:
billingStatus: p.enum('billing_status', {
label: 'Account status',Then look at the plan, and apply it:
npx kalup plan
npx kalup applyThe plan lists each step with what it writes, such as label: "Billing status" -> "Account status". apply plans again, prints the plan and asks you to type the target name, on every target. It reads each write back. To have someone review a plan before it runs, or to apply from CI, save it with npx kalup plan --out and apply the file it names. See kalup plan and kalup apply.
Use the files in your app
import { Company } from './hubspot/index.js' // a bundler also resolves './hubspot'
const status = Company.properties.billingStatus.get(record.properties)
// 'active' | 'PAST DUE' | Unlisted | null
const update: Record<string, string> = {}
Company.properties.renewalDate.clear(update) // { renewal_date: '' }, how HubSpot empties a valueThe same file types the property bag, with no generate step. Each codec's property is its internal name as a literal type, such as 'billing_status'. Unlisted is a value HubSpot holds that the options do not list yet, such as one an admin added; .strict() on the builder throws on one instead. Types and codecs has the details.
What to commit
Commit kalup.config.ts, everything under hubspot/ (the object files, index.ts, and removed.ts once kalup rm writes it), AGENTS.md and CLAUDE.md. The files are the source of truth for the portal, and a change to them is a normal pull request.
Do not commit .kalup/ (history copies, local state, saved plans, journals and snapshots), a plan file such as plan.json, .env or any other file that holds a key, or the IR. npx kalup ir derives the IR from the files whenever something needs it.
State stays on your machine by default. To share it with teammates, add state: 'repo' to kalup.config.ts: state then lives in hubspot/state/ and you commit it with the files, at the cost of a merge when two branches apply to the same portal. See kalup.config.ts.
The other commands
| Command | What it does |
|---|---|
kalup ir | Print the ir/1 document derived from the files. --check validates it and prints only issues |
kalup fmt | Rewrite the files in canonical form and regenerate hubspot/index.ts. --check lists the files that would change and exits 2 when there are any |
kalup status | The config summary, then for each target: key set, portal matches the pin, account type, read scopes present, state |
Next steps
- One admin, one portal: the same path in more depth, an edit made in the HubSpot UI, and recovery.
- Drift: what happens when someone edits the portal in HubSpot.
- Sandbox, production and CI and Agencies and blueprints: the other two ways to work.
- How Kalup works: targets, the files, the IR and the portal, and how they fit.
- kalup.config.ts: narrow the pull scope and add a second target.
- Property builders: add a property by hand and see its type change in your editor.
- Working with AI agents: let Claude Code or another agent drive the CLI safely.
- Errors: every error code, what causes it and the fix.
Kalup is an independent open-source project maintained by Scopious. It is not affiliated with, endorsed by, or sponsored by HubSpot, Inc. HubSpot is a registered trademark of HubSpot, Inc.