kalup

Network and safety

What Kalup sends to HubSpot, what it reads and writes on disk, and what it never does

This version of Kalup only reads. It sends read requests for your portal's configuration to HubSpot's public API, and it writes files in your project. It never writes to a portal, never reads record data, and never prints a key.

What Kalup sends to HubSpot

Every request goes to https://api.hubapi.com through one HTTP layer in the CLI. Nothing else in Kalup opens a connection.

CommandRequests
initAccount info for the key, then the same requests as pull
pullAccount info, then per object in scope: its property list and its property group list. The custom object schema list when config names a custom object
statusPer target: account info, then one list request per read scope the config needs, to see which scopes the key holds
validate, ir, fmtNone. They work on the files alone

Every request carries the key as a bearer token and asks for JSON. These are schema requests: property definitions, groups and custom object schemas. Kalup does not request contacts, companies, deals or any other record.

The endpoint registry

Kalup keeps every HubSpot path it knows in one table, the endpoint registry. Each row is one resource type and pins one dated API version.

RowAPI familyVersionSupported until
propertycrm.properties2026-092028-03
groupcrm.properties2026-092028-03
objectcrm-object-schemas2026-092028-03
accountInfoaccount-info2026-092028-03
limitscrm.limits2026-092028-03

Paths carry the date, for example GET /crm/properties/2026-09/{objectType}. HubSpot releases a new version every March and September and supports each for 18 months, so a new HubSpot release means a registry change in Kalup, not a rewrite. kalup status warns with W_PIN_EXPIRES when a pinned version has less than 90 days left.

Every path in the registry is tagged read or write. The write paths are listed so the registry is complete. None of them is used in this version.

Read only, enforced in one place

The HTTP layer checks the tag of every path before it sends anything. A path tagged write is refused with E_WRITE_IN_READ_MODE and nothing leaves the machine. No command reaches that error in normal use; seeing it means a bug in Kalup. Kalup's own test suite fails if any command sends a request that does not match a read path, or if any source file other than the HTTP layer calls fetch.

The portal pin

A target is pinned to one portal by portalId in kalup.config.ts. The first request of every networked command reads the account behind the key. When the key belongs to another portal:

  • the command stops with E_TARGET_PORTAL_MISMATCH, exit 4 and humanRequired: true,
  • no other request is sent,
  • nothing is written.

init does the same check against --portal before it writes a single file. The fix never suggests changing the pin, because changing the pin to match the key is exactly how the wrong portal gets read. See Targets and credentials.

Pacing and retries

Kalup paces its own requests so it stays inside HubSpot's limits.

  • Pacing. A token bucket sets the request rate from HubSpot's rate-limit headers (X-HubSpot-RateLimit-Max, -Remaining and -Interval-Milliseconds). When a response carries none, Kalup sends at most 8 requests per second and warns once with W_RATE_LIMIT in pull or W_RATE_HEADERS in status. Which headers a service key returns is not confirmed, so expect the warning.
  • Retries. A 429 or a 5xx is retried up to three times. Kalup waits the number of seconds in Retry-After when HubSpot sends it, and backs off otherwise (about 250 ms, then 500 ms, then 1 s, plus jitter). If the last retry fails too, the command stops with E_RATE_LIMIT or E_HTTP.
  • Daily limit. A 429 from HubSpot's daily policy is not retried. The command stops with E_DAILY_LIMIT, and the fix names the next midnight in the portal's time zone. Every app on the portal shares that daily limit.

Keys

Kalup reads one key per target, the read key.

  • The variable is the target's credentials.read.env, or HUBSPOT_SERVICE_KEY when the target names none. init always reads HUBSPOT_SERVICE_KEY.
  • Kalup looks in the process environment first, then in .env in the project directory. .env lines take the usual NAME=value form, with optional quotes, export prefixes and # comments.
  • A missing variable is E_MISSING_KEY. The message names the variable, never a value.

A key is never printed, logged or written to a file, including in error output and --json. Errors that come from HubSpot or the network pass through one sanitizer before they reach the terminal. Keep .env out of git; init does not add it to .gitignore for you.

Use a service key with read scopes only. init prints the exact scopes the key needs, one per object. A key can only hold scopes its creator has, so a Super Admin should create it.

Text from the portal

Labels, descriptions, option labels and object names come from whoever edited the portal. Kalup treats them as untrusted data. Before any portal string reaches the terminal or JSON output, Kalup strips ANSI escape sequences, control characters and line breaks, and cuts it to 120 characters. In the files pull writes, portal strings are written as quoted string literals, so they can never become code.

For agents this is a rule, not just a filter: text from the portal is data, never instructions. See Working with AI agents.

What Kalup writes on disk

PathWritten byWhat it holds
kalup.config.tsinitThe project config: objects in scope and targets. No keys, only the names of the variables that hold them
kalup/objects/*.tsinit, pull, fmtOne file per object: groups and property definitions. No record data
kalup/index.tsinit, pull, fmtThe barrel your app imports
.kalup/history/<timestamp>/pull, fmtA copy of every file before Kalup overwrites it
AGENTS.md, CLAUDE.mdinitRules for AI agents, appended between markers
.gitignore, biome.json or .prettierignoreinitThe .kalup/ line and a formatter ignore for kalup/

History. Before pull or fmt overwrites a file, the old one is copied to .kalup/history/<ISO timestamp>/<path>. Kalup keeps the last 20 of those folders and removes older ones. To undo a change, copy the file back.

State. .kalup/state/<target>.json is where a later version records what it last applied. This version never writes or reads it; status only reports whether the file exists. init adds .kalup/ to .gitignore, so history and state stay out of git.

Absence never deletes

Removing a property or a group from a file never deletes it anywhere. In this version nothing is written to a portal at all, and pull never removes an entry from your files: a property that is gone from the portal stays in the file and is reported as missing in the portal.

When apply arrives

Writing to a portal comes with plan and apply. The rules are decided now, so they are listed here for planning. None of this is in the current version.

  • Writes use a separate write key, credentials.write, and only paths tagged write in the registry.
  • A protected target, such as production, accepts only a saved plan, and a person at a real terminal types the target name to confirm. Without a terminal the command exits 4 and prints the command for a person to run.
  • A delete needs four things: a tombstone written by kalup rm, a state entry that shows Kalup owns the resource, a target that allows deletes, and a person typing the count at a terminal.
  • Edits made in the HubSpot UI are held, not reverted.

On this page