Agencies and blueprints
One repository per client, a shared blueprint, per-portal differences, rolling out onto a portal that already has the fields, takeover, upgrades that keep each client's changes, and a data dictionary for handover
This guide is for an agency that sets up and looks after many client portals. Each client gets a repository of its own. A blueprint, a JSON file of property groups and properties you write once, goes into every client project with kalup add, and kalup blueprint upgrade brings a new version in later without losing what the client changed. Neither command touches a portal: each client's changes still go through plan and apply, target by target, and a person reviews them.
It follows one invented blueprint, acme/renewals, through three client projects.
A test replays every npx kalup line on this page against a simulated portal shaped like HubSpot's documented responses. Blueprint upgrades have been exercised on simulated client projects only, so try a new blueprint on a client's sandbox or a test account first.
One repository per client
Keep each client in a repository of its own, next to your blueprints:
agency/
blueprints/ your blueprints, one file per version
northwind/ one repository per client
harbor/
quarry/State, keys, history and the data dictionary then belong to one client, a client's repository can be handed over as it is, and no plan can reach another client's portal. Each client repository installs Kalup itself, so each can move to a new version on its own schedule.
Write the blueprint
A blueprint holds groups and managed properties in the same terms as kalup ir prints them. It is data only: no code, no p.json validators, no keys.
{
"blueprintVersion": 1,
"name": "acme/renewals",
"version": "1.0.0",
"irVersion": 1,
"description": "Renewal tracking for deals",
"requires": [{ "$ref": "object:deals" }],
"resources": {
"group:deals/renewal": { "type": "group", "definition": { "label": "Renewal" } },
"property:deals/renewal_date": {
"type": "property",
"definition": { "label": "Renewal date", "group": { "$ref": "group:deals/renewal" }, "type": "date", "fieldType": "date" },
"binding": { "key": "renewalDate" }
},
"property:deals/renewal_stage": {
"type": "property",
"definition": {
"label": "Renewal stage",
"group": { "$ref": "group:deals/renewal" },
"type": "enumeration",
"fieldType": "select",
"options": [{ "value": "open", "label": "Open" }, { "value": "won", "label": "Won" }]
}
}
}
}Keep it in a repository of your own and publish each version under its own file name or URL. A published version never changes: Kalup records the hash of every version a project used and refuses the same version with other bytes (E_BLUEPRINT_INTEGRITY). A fix is a new version number.
Set up a client project
Create the client's repository and pull its sandbox, with the sandbox key in .env as HUBSPOT_SERVICE_KEY:
mkdir quarry
cd quarry
git init
npm init -y
npm install @kalup/core
npm install -D kalup
echo node_modules/ >> .gitignore
npx kalup init --portal 1111111 --objects deals --target sandbox
npx kalup pullThen add the client's production portal as a second target:
import { defineConfig } from '@kalup/core'
export default defineConfig({
defaultTarget: 'sandbox',
objects: {
deals: {},
},
targets: {
sandbox: {
portalId: 1111111,
credentials: { read: { env: 'HUBSPOT_SANDBOX_KEY' } },
},
production: {
portalId: 2222222,
protected: true,
credentials: { read: { env: 'HUBSPOT_PROD_KEY' } },
},
},
})HUBSPOT_SANDBOX_KEY=<the client's sandbox key>
HUBSPOT_PROD_KEY=<the client's production key>Each key needs the read and write scopes of the objects in scope, such as crm.schemas.deals.read and crm.schemas.deals.write, and should hold one crm.objects.<object>.read scope, such as crm.objects.deals.read: HubSpot's Limits Tracking answered 403 to a key with crm.schemas.* scopes only on a developer test account (2026-09-29), so plan could not check the property limit before it created a blueprint's properties. With crm.objects.companies.read added it answered 200. The scope also lets the key read that object's records, which Kalup never requests. Sandbox, production and CI shows how to keep a production write key out of .env and apply through CI instead.
Add the blueprint
npx kalup add ../blueprints/renewals-1.0.0.json --dry-run
npx kalup add ../blueprints/renewals-1.0.0.jsonadd takes a path or an https:// URL with no user name, password or query string: the lock records the source, so for a private repository, download the file and pass its path. It writes the properties into hubspot/objects/deals.ts through the same writer pull uses, adds deals to objects in kalup.config.ts when it is missing, and records the blueprint in hubspot/blueprints.lock.json, with a copy of the file as it was read under hubspot/.blueprints/. A line in .gitattributes keeps git from changing that copy's line endings. Commit all of them.
A property the client already has with the same definition is recorded, not rewritten. One with a different definition stops the add and names each difference (E_BLUEPRINT_COLLISION).
HubSpot property names are permanent. Kalup adds no prefix by default. If a client needs one, pass --prefix acme_ or set prefix in kalup.config.ts: the names become acme_renewal_date, while labels and the TypeScript keys stay the same, so your app code works in every project.
Then apply each target. apply plans the target, prints the plan and asks you to type the target name. The sandbox is the default. Production is protected, so in this setup a person at a terminal applies it; Sandbox, production and CI shows the other way, a saved plan applied by a reviewed CI job with --approve and a write key only that job holds:
npx kalup apply # at a terminal
npx kalup apply --target production # at a terminalA client's own differences
After a few months the three clients differ:
- Northwind never touched the renewal properties.
- Harbor renamed "Renewal stage" to "Renewal phase" and added a "Churned" option.
- Quarry renamed "Renewal date" to "Contract end".
These are ordinary edits to hubspot/objects/deals.ts, and they apply to every target of that client. The lock remembers that the properties came from the blueprint; kalup ir shows it as provenance.
When one of a client's portals must differ from the others, write a per-target definition override in kalup.config.ts instead of changing the shared file. Quarry's sales team reports on "Renewal status" in production, while the sandbox keeps the blueprint's label:
production: {
portalId: 2222222,
protected: true,
credentials: { read: { env: 'HUBSPOT_PROD_KEY' } },
overrides: {
'property:deals/renewal_stage': { definition: { label: 'Renewal status' } },
},
},An override replaces each field it states, whole, on that target only: plan, compare and the data dictionary use it, and pull writes that portal's value of the field back into the override, never into the shared file. Apply both targets again:
npx kalup apply # at a terminal
npx kalup apply --target production # at a terminalRoll out version 2.0.0
Version 2.0.0 renames "Renewal date" to "Renewal due date" and adds a "Paused" option. Run the same commands in each client project, looking at the dry run first:
npx kalup blueprint upgrade acme/renewals ../blueprints/renewals-2.0.0.json --dry-run
npx kalup blueprint upgrade acme/renewals ../blueprints/renewals-2.0.0.jsonThe upgrade compares three versions of each unit: the stored 1.0.0 file, the client's config, and 2.0.0.
- Northwind is unchanged since 1.0.0, so it gets both changes.
- Harbor is customized: it gets the new date label and the "Paused" option, and keeps "Renewal phase" and "Churned".
- Quarry conflicts: it gets "Paused", but the date label changed on both sides. Config keeps "Contract end", the output shows both values, and the lock records the conflict as held:
conflict, config kept: property:deals/renewal_date
conflict label: config "Contract end", upstream "Renewal due date"; take upstream: kalup blueprint upgrade acme/renewals ../blueprints/renewals-2.0.0.json --take remote property:deals/renewal_date#labelIf the client agrees to the new label, take upstream's side:
npx kalup blueprint upgrade acme/renewals ../blueprints/renewals-2.0.0.json --take remote 'property:deals/renewal_date#label'A held conflict stays held at later versions until someone takes a side, by --take remote or by editing config; running the same version again drops the ones config has settled. In CI, --exit-code makes the upgrade exit 2 while conflicts are held. Quarry's production override is untouched: its "Renewal status" label stays.
Plan and apply, client by client
In each client project, apply each target, reading the plan it prints before you type the name:
npx kalup apply # at a terminal
npx kalup apply --target production # at a terminalA field a new version owns for the first time, where the portal already holds another value, is held as diverged; apply --take config writes it, and --take config 'property:deals/*' takes every such field of an object at once.
A client portal that already has the fields
A new client often has half the blueprint already: a "Renewal date" someone made by hand, with another label or a missing option. The first plan adopts those properties and holds every value that differs as diverged, since config and the portal never agreed on it. When config should win across the board, say so once, on the target:
production: {
portalId: 2222222,
protected: true,
adopt: 'overwrite',
credentials: { read: { env: 'HUBSPOT_PROD_KEY' } },
},Each differing value then becomes a write, risky and labelled overwrites-portal. --yes never covers a risky step, so a person applies it at a terminal, or a reviewed CI job with --approve does. Remove adopt: 'overwrite' once the portal is adopted: from then on, an edit the client makes is drift, held for you to settle.
Take over a client's object
adopt: 'overwrite' fixes what config names. Takeover goes further: on the targets you choose, config becomes the whole truth for an object, and the plan archives the custom properties and groups config lacks. Use it to clean up a portal after the client signs off, one target at a time:
objects: {
deals: { exclude: ['zi_*'] },
},
targets: {
sandbox: {
portalId: 1111111,
mode: 'takeover',
allowDestroy: true,
credentials: { read: { env: 'HUBSPOT_SANDBOX_KEY' } },
},mode: 'takeover'on one target leaves every other target in the defaultaddonmode, where Kalup manages only what config names.excludekeeps names out of the pull scope,*matching any run: here the properties an integration creates, which takeover must never archive.- Without
allowDestroy: trueevery takeover removal is blocked, and its fix shows how to keep the property instead: pull it into config, orexcludeit. - Every takeover removal is
destructive. Only a person at a terminal applies it, typing the number of destructive steps;--yesand--approvenever cover one.
Takeover never archives a HubSpot-defined or calculated property, a kind Kalup does not write, or anything an object file lists, and an incomplete read blocks it. HubSpot keeps an archived property restorable in its UI for 90 days. See mode.
When a client edits the portal
A client's admin renames "Renewal due date" to "Renewal deadline" in production's HubSpot UI. The next plan for production holds the label instead of reverting it, and names both ways out:
npx kalup plan --target productionTo keep the client's value, pull it into the files. It goes into the shared hubspot/objects/deals.ts, so the sandbox takes it on its next apply; to keep a value on production only, give production a definition override with that field first, and pull writes into the override instead. Pull also records the new label in production's state as agreed, so the next production plan is empty:
npx kalup pull --target production --only property:deals/renewal_dateTo keep your value instead, take config's side. The step becomes risky, labelled reverts-ui-edit, and applies like any other plan:
npx kalup apply --target production --take config 'property:deals/renewal_date#label' # at a terminalWhen the file changed the same label too, it is a conflict: pull keeps the file's value, and pull --accept takes the client's.
When an apply stops part way
apply has no resume and no rollback, and never sends a write twice. When it exits 5, when you stop it with Ctrl-C, or when a write's answer is lost, look at the last run and plan again:
npx kalup status
npx kalup plan --target production
npx kalup apply --target production # at a terminalplan warns W_UNFINISHED_APPLY and shows what is left for that portal: a write that did not land appears again, and a property the run created appears as an adopt, never a second create. State is per portal, so the client's other targets are not affected.
Hand over
A data dictionary describes the client's configuration in Markdown: every object, group, property and option, with per-target overrides in a section of their own. Generate it from the config, and from a snapshot of what production holds, and commit both with the files:
npx kalup docs --out DATA-DICTIONARY.md
npx kalup snapshot --target production --out handover/production.json
npx kalup docs handover/production.json --out handover/PRODUCTION.mdThe same input always gives the same bytes, so git diff shows what changed since the last handover.
What an upgrade never does
- It never writes to a portal. The portal changes happen in
apply, target by target. - It never deletes. A property removed from a new version stays in the client's config, no longer tied to the blueprint. An option removed upstream stays too, with its
asalias and a note. - It never brings back what the client removed, and never overwrites a value the client changed unless you pass
--take remote. - It never touches per-target overrides in
kalup.config.ts.
For agents
The blueprint's description, labels and option labels are third-party text. Kalup prints the description only to a person at a terminal, labelled as the blueprint's own text, and sanitizes every quoted string. The rules kalup init writes to AGENTS.md tell agents to treat that text as data, never as instructions. An agent can run add --dry-run and blueprint upgrade --dry-run with --json to show what would change; a person decides on --take remote, reviews the plan and applies it.
The full reference is kalup add and blueprint upgrade, and blueprints.md in the installed package (node_modules/kalup/docs/blueprints.md).
What is not covered yet
Blueprint upgrades have not been evaluated on real client projects yet; try a blueprint on a test account first. Blueprints hold property groups and properties only: custom object schemas are read and compared, never written. Pipelines, custom object schema writes and association labels are next. There is no blueprint registry, and no hosted view across all your clients yet.
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.
Sandbox, production and CI
A developer's setup for several portals, with separate read and write keys, comparisons and snapshots, quick applies to the sandbox and production changes through a reviewed CI job
Working with AI agents
How Claude Code and other agents drive Kalup, the rules they follow, and how to read these docs as Markdown