kalup

Getting started

Install the CLI, pull a portal into files, validate them and commit

This page takes you from an empty directory to a committed set of config files. Everything on it is read-only: this version of Kalup reads a portal and never writes to it.

Before you start

  • Node 22 or newer.
  • The portal's Hub ID, from the account menu in HubSpot.
  • A service key for that portal with the read scopes for the objects you want. init prints the exact list. A key can only hold scopes its creator has, so a Super Admin should create it.

Install

npm install --save-dev kalup
npm install @kalup/core

kalup is the CLI. @kalup/core is the runtime your app imports. It has no dependencies and never touches the network or the file system.

Put the key in .env in the project directory, or in the environment:

HUBSPOT_SERVICE_KEY=...

The CLI reads the variable a target names in credentials.read.env, or HUBSPOT_SERVICE_KEY when the target names none. It never prints a key, not even in an error. Keep .env out of git.

Create the project

npx kalup init --portal 1111111

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

  • kalup.config.ts with one target. The target is named sandbox for a sandbox or developer test account and production for a standard account, which also gets protected: true. --target <name> picks another name.
  • kalup/index.ts, the barrel your app imports.
  • The line .kalup/ in .gitignore, created when missing.
  • A formatter ignore for kalup/: !kalup/** in biome.json when that file exists, else a line 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.

Then it runs the first pull. --objects contacts,companies,deals is the default scope. A custom object goes in by its name, for example --objects companies,subscription. The output starts with the account type, the UI domain and the time zone, the target and its objects, the read scopes the key needs, one per object, with the link to the service key settings, and the files written. The pull summary follows.

init refuses to run when kalup.config.ts already exists. Run pull instead.

Pull

npx kalup pull --target sandbox

pull reads the target and writes one file per object under kalup/objects/. A second pull with no change in the portal writes nothing. Run it whenever someone changed the portal in the HubSpot UI, so the files catch up. Before a file is overwritten, the old one is copied to .kalup/history/.

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' },
      ],
    }),
    domain: p.string('domain'),
    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 }

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 are references: 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.

Validate

npx kalup validate

validate runs offline. It checks kalup.config.ts and every file under kalup/ 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": "kalup/objects/companies.ts", "line": 12, "fix": "..." }] }

That is what a script or an AI agent reads. issues[].fix says what to do.

Use the files in your app

import { Company } from './kalup'

const status = Company.properties.billingStatus.get(record.properties)
// 'active' | 'PAST DUE' | null

The same file types the property bag, with no generate step. Types and codecs has the details.

What to commit

Commit kalup.config.ts, everything under kalup/, 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, and state in a later version), .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.

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 kalup/index.ts. --check lists the files that would change, exit 2 with --exit-code
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