kalup

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 kalup

With 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 needs crm.schemas.custom.read.
  • crm.schemas.<object>.write, to apply changes. Leave it out for objects you only read.
  • One crm.objects.<object>.read, such as crm.objects.contacts.read, so plan can 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:

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

init needs no key and sends nothing to HubSpot. It writes:

  • kalup.config.ts with one target named production, pinned to portal 1111111. --target <name> picks another name; a name gives the target no role. Without --portal the target is pending until you set portalId.
  • hubspot/index.ts, the barrel your app imports, empty until the first pull. --dir lib/config/hubspot puts the folder elsewhere.
  • The lines .kalup/ and .env in .gitignore, created when missing, so state, history and the key stay out of git. In a monorepo init edits the .gitignore and formatter config it finds up to the repository root instead.
  • A formatter ignore for hubspot/ and kalup.config.ts: !hubspot, !kalup.config.ts and !.kalup added to files.includes in biome.json or biome.jsonc when one exists, with the file's comments kept, else the lines hubspot/ and kalup.config.ts in .prettierignore when a Prettier config exists, else a printed note. The writer's format is the only format for those files.
  • AGENTS.md with the rules an AI agent follows in this project, appended when the file exists.
  • CLAUDE.md with 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 at AGENTS.md.
  • @kalup/core in dependencies in package.json, when no dependency list has it. Run your package manager's install afterwards; init does 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 pull

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

With 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 validate

validate 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:

hubspot/objects/companies.ts
    billingStatus: p.enum('billing_status', {
      label: 'Account status',

Then look at the plan, and apply it:

npx kalup plan
npx kalup apply

The 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 value

The 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

CommandWhat it does
kalup irPrint the ir/1 document derived from the files. --check validates it and prints only issues
kalup fmtRewrite 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 statusThe config summary, then for each target: key set, portal matches the pin, account type, read scopes present, state

Next steps

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.

On this page