Ship the property before the code that needs it.
You have an app, a sandbox and a production portal. Kalup keeps the portal's properties and objects in TypeScript files next to your code. The same files give the app its types, and CI makes sure a property exists before the code that reads it goes live.
A property, from branch to production.
The same review as any other change: a branch, a diff, a pull request and a CI job.
- 01
Add the property on a branch
Add one line tokalup/objects/companies.ts. The app code that usesCompanyData.renewalDatetype-checks at once. No generate step. - 02
Plan and apply to your sandbox
$ kalup plan --target sandboxThe plan shows one safe create. Apply it to the sandbox and test against a real portal. - 03
Open a pull request
$ kalup plan --target production --jsonCI plans against production and posts the plan as a comment, so reviewers see the portal change next to the code change. - 04
Merge, then CI applies
$ kalup apply plan.jsonA CI job that holds the production write key plans again, saves the plan and applies it, then deploys the app. The property exists before the code that needs it runs. - 05
Roll back with a revert
There is no rollback command. Revert the commit and plan again. Recovery is always a new plan.
The plan rides along with the pull request.
State lives on a kalup-state branch, checked out as a worktree. A rejected push means another apply ran at the same time.
// kalup/objects/companies.ts
renewalDate: p.date('renewal_date', { label: 'Renewal date', group: 'billing', fieldType: 'date' }),$ npx kalup plan --target production Reading portal 2222222 (production) ... done Plan for production: 1 step · 1 safe + Create company property "Renewal date" safe property:companies/renewal_date Nothing was written. Review, then apply. $
# .github/workflows/kalup.yml, after merge to main
- 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))A typed client on the same files
@kalup/client reads, writes and searches records with the types from your object files. Aliases, required fields and read-only properties carry through, so 'PAST DUE' in the portal is 'past_due' in your code.
import { createClient } from '@kalup/client'
import { Company } from './kalup'
const crm = createClient({ accessToken: process.env.HUBSPOT_TOKEN, objects: { companies: Company } })
const company = await crm.companies.get('1234')
company.billingStatus // 'active' | 'past_due'
for await (const c of crm.companies.searchAll({ where: { billingStatus: 'past_due' } })) {
// every past-due company, past the 10,000-result cap
}What Kalup does not do for you.
Knowing where it stops is part of trusting what it does.
- It does not move records.
Kalup moves configuration, never contacts, companies or deals. The typed client reads and writes records for your app; it is not a migration engine.
- It does not replace the hs CLI.
Apps, CMS themes and serverless functions stay with HubSpot's own CLI. Use hs for the app and Kalup for the portal.
- It has no rollback button.
Recovery is a revert and a new plan. A partial apply exits 5, and you plan again from where the portal is.
Start with one portal.
kalup init pins a target, writes the files and runs the first pull. Nothing is written to the portal.