HAL is configured via a config file in the config directory (default: the current working directory, or --config when set). The recommended way to create or complete your config is the Setup wizard — run npx @marcopeg/hal wiz, or run npx @marcopeg/hal and accept the prompt when HAL detects the config is missing/incomplete. Three formats are supported — only one per file is allowed.
| Format | Extension | Features |
|---|---|---|
| JSON | .json |
Standard JSON |
| JSONC | .jsonc |
JSON with // and /* */ comments, trailing commas |
| YAML | .yaml / .yml |
Full YAML syntax with native comments |
| File | Purpose |
|---|---|
hal.config.{json,jsonc,yaml} |
Main config (required) |
hal.config.local.{json,jsonc,yaml} |
Local overrides (optional, gitignored) |
Base and local configs can use different formats (e.g. hal.config.yaml + hal.config.local.json). If multiple formats exist for the same file (e.g. both .json and .jsonc), the loader exits with an error.
This section is the index for all configuration options; detailed subsections are split into focused docs.
Create a config file in your config directory (default: the current working directory, or --config when set). Secrets like bot tokens should be kept out of this file — use ${VAR_NAME} placeholders and store the values in .env or the shell environment instead. Keep .env out of git (see Env files).
YAML is the recommended format for examples and for configs with comments. A full key reference (all options, with links to doc pages) is reference.yaml. A short copy-paste example is examples/hal.config.yaml. JSON and JSONC are also supported — see Configuration alternatives below.
Example (YAML):
globals:
engine:
name: claude
logging:
level: info
flow: true
persist: false
rateLimit:
max: 10
windowMs: 60000
access:
allowedUserIds: [123456789]
projects:
backend:
cwd: ./backend
telegram:
botToken: "${BACKEND_BOT_TOKEN}"
logging:
persist: true
frontend:
cwd: ./frontend
engine:
name: copilot
model: gpt-5-mini
telegram:
botToken: "${FRONTEND_BOT_TOKEN}"An optional local config file placed next to the main config is deep-merged on top of the base config at boot time. It is gitignored and is the recommended place for machine-specific values or secrets that you don't want committed.
Every field is optional. projects is a map with the same keys as the base config; each local entry is deep-merged into the base project with the same key. Keys that do not exist in the base config are invalid and cause a load error — you cannot introduce new projects from local config.
projects:
backend:
telegram:
botToken: "7123456789:AAHActual-token-here"
logging:
persist: truehal.config.json and hal.config.jsonc are supported alongside hal.config.yaml. Runtime behavior is identical; the loader accepts any of the three formats (one per file).
For a full config structure use the YAML reference or example; you can convert to JSON/JSONC (e.g. with a tool or AI) if needed.
JSONC supports:
- Single-line comments:
// - Block comments:
/* ... */ - Trailing commas in objects and arrays
JSONC does not support: unquoted keys, single-quoted strings, or other non-standard JSON extensions.
Minimal JSON example (globals + one project):
{
"globals": {
"engine": { "name": "claude" },
"access": { "allowedUserIds": [123456789] }
},
"projects": {
"mybot": {
"telegram": { "botToken": "${BOT_TOKEN}" }
}
}
}Minimal JSONC example (same structure with // comments and trailing commas):
Any string value in the config files can reference an environment variable with ${VAR_NAME} syntax. Values inside context blocks support the same ${expr} syntax but with a richer resolver (full context map + env) and two additional patterns (#{cmd} boot-time shell, @{cmd} message-time shell) — see Context. This works identically for all config formats (JSON, JSONC, YAML).
Variables are resolved at boot from env files next to your config (.env and .env.local). The docs use .env as the standard file; .env.local is an optional override. For full details on loading modes, precedence, the env config key, wizard file selection, and .gitignore guidance, see Env files.
If a referenced variable cannot be resolved from any source the bot exits at boot with a clear error message naming the variable and the config field that references it.
Default settings applied to all projects. Any setting defined in a project overrides its global counterpart.
| Key | Description | Default |
|---|---|---|
globals.engine.name |
Required (unless every project sets its own). Engine: claude, copilot, codex, opencode, cursor, antigravity |
— |
globals.engine.command |
Override the CLI command path | (engine name) |
globals.engine.model |
Override the AI model (see Engines) | (per engine) |
globals.engine.enforceCwd |
Inject a system instruction telling the agent to treat project.cwd as the working boundary for file operations. Applies to normal prompts and markdown cron prompts. Disable only when the agent intentionally needs to roam outside the project directory. |
true |
globals.engine.session |
Session mode: false (stateless), true (adapter default, omit = same), "shared", or "user". See Session configuration. "user" with OpenCode fails at boot. For Codex and Copilot, true now resolves to per-user mode. |
true |
globals.engine.sessionMsg |
Message sent when renewing session (e.g. /clear) |
"hi!" |
globals.engine.envFile |
Path to an env file sourced before running the engine CLI (child process only; not for HAL config substitution). Relative to project cwd; absolute paths used as-is. Active projects with a missing/unreadable file fail at boot. |
(none) |
globals.engine.codex.* |
Codex permission flags | See Codex |
globals.engine.antigravity.* |
Antigravity flags | See Antigravity |
globals.logging |
Log level, flow, persist | See Logging |
globals.rateLimit |
Max messages per user per time window | See Rate limit |
globals.access.allowedUserIds |
Telegram user IDs allowed by default (entries may be numbers or strings for env substitution; after substitution they are validated and normalized to numeric IDs) | [] |
globals.access.dangerouslyAllowUnrestrictedAccess |
Allow all users without a whitelist (must be explicitly true) |
false |
globals.dataDir |
Default user data directory | (see dataDir below) |
globals.transcription.model |
Whisper model for voice | "base.en" |
globals.transcription.mode |
Voice transcript UX mode: confirm (buttons + confirm/cancel), inline (show transcript while processing), silent (no transcript shown) |
"confirm" |
globals.transcription.showTranscription |
Legacy compatibility flag (maps to mode: inline when sticky: false) |
(deprecated) |
globals.transcription.sticky |
Legacy compatibility flag (maps to mode: confirm when true) |
(deprecated) |
globals.commands |
Toggle and configure built-in/system commands | See Commands config and System commands |
Per-engine options (Codex, Antigravity) are documented in Engines.
engine.session is a single value: false (stateless), true (adapter default), "shared", or "user". Full reference, per-engine behaviour, and boot-error rules: Session configuration.
Important: true means the engine default, not the same behavior for every engine. In particular, Codex and Copilot now default to per-user mode, while Cursor still defaults to shared mode.
Per-engine model lists for the /model command and the set of engines available for /engine. Top-level sibling of globals and projects. Entries may include default: true (at most one per engine) to set the model when engine.model is omitted. Explicit engine.model always overrides the provider default. See Engines — Model list for full details, field reference, and examples.
Per-project providers can override the top-level list for a specific project (only for engines already listed in the base config; local config cannot add new engine keys).
Configuration shapes:
| Config shape | Meaning |
|---|---|
No providers key |
HAL runs a fast CLI check at boot; if more than one engine is available, /engine is enabled with that list. |
providers: {} or providers: (no sub-keys) |
Engine and model switching disabled. No boot discovery. Projects cannot change engine or model via /engine or /model. |
providers: { opencode:, codex: } (empty lists) |
Only opencode and codex appear in /engine. Default models or CLI auto-discovery (OpenCode/Cursor) for /model. |
providers with one or more engine keys |
Every project’s engine.name must be one of those keys; otherwise HAL fails at boot with a clear error. |
When a built-in command is disabled, HAL does not intercept it. The slash command falls through to project custom commands, global custom commands, skills, and finally the agent.
Every project must have a valid access policy or the bot refuses to start. A valid policy is one of:
access.allowedUserIdscontains at least one Telegram user ID, oraccess.dangerouslyAllowUnrestrictedAccessis explicitlytrue.
When allowedUserIds is non-empty it takes precedence — only listed users are allowed, even if dangerouslyAllowUnrestrictedAccess is also true.
Format: Each allowedUserIds entry may be a number (e.g. 123456789) or a string (e.g. "123456789" or "${TELEGRAM_USER_ID}" for env substitution). After environment variable substitution, every value is validated as a valid Telegram user ID (positive integer in the official range) and normalized to a number. Invalid values (e.g. spaces, decimals, non-numeric text) cause config load to fail with an error that includes the config path and the invalid value; the process exits at boot or on hot reload.
Project-level replacement: if a project defines access (even as "access": {}), it fully replaces the global access — the two are not merged. If a project omits access entirely, the global value is inherited. An empty "access": {} at project level is a validation error because it has neither user IDs nor the dangerous flag.
This validation runs at both initial boot and after config hot-reload. A reload that introduces an invalid access config is rejected and the previous config stays active.
projects is an object (map) keyed by project key. Each key identifies one project and one Telegram bot connected to one directory. This key is used in logs, data paths, and errors (legacy internal name: slug).
Key format: Only letters, numbers, dashes, and underscores ([a-zA-Z0-9_-]+). This keeps the default cwd safe as a path segment when omitted.
Defaults from key: If you omit name, it defaults to the map key. If you omit cwd, it defaults to the map key (so a key backend implies cwd: "backend" unless overridden). You can still set name and cwd explicitly to override these defaults.
| Key | Required | Description |
|---|---|---|
name |
No | Display name; defaults to the project key (map key) |
active |
No | Set to false to skip this project at boot (default: true) |
cwd |
No | Path to the project directory (relative to config file, or absolute); defaults to the project key |
telegram.botToken |
Yes | Telegram bot token from BotFather |
access.allowedUserIds |
No | User whitelist for this bot — numbers or strings (env substitution supported); validated and normalized to numeric IDs (replaces global access when set) |
access.dangerouslyAllowUnrestrictedAccess |
No | Allow all users for this bot (replaces global access entirely when set) |
engine.name |
No | Override the engine for this project (required if globals does not set one) |
engine.command |
No | Override the CLI command path |
engine.model |
No | Override the AI model (see Engines) |
engine.enforceCwd |
No | Override the HAL cwd anchor instruction for this project. When true or omitted, HAL prepends a system instruction telling the agent to keep file operations inside the resolved project cwd; set false only for intentional cross-directory workflows such as monorepos. |
engine.session |
No | Session mode for this project: false | true | "shared" | "user" (see Session configuration). For Codex and Copilot, omitted or true now means per-user mode. |
engine.sessionMsg |
No | Message used when renewing session |
engine.envFile |
No | Path to an env file sourced before running the engine CLI (child process only). Relative to this project's cwd or absolute. Missing/unreadable at boot causes boot failure for this project. |
engine.codex.* |
No | Codex permission flags (see Codex) |
engine.antigravity.* |
No | Antigravity flags (see Antigravity) |
providers |
No | Override the top-level model list for this project; entries may include default: true (at most one per list). See Engines. |
logging |
No | Override logging (see Logging) |
rateLimit |
No | Override rate limit (see Rate limit) |
transcription.mode |
No | Override voice transcript UX mode: confirm | inline | silent |
transcription.showTranscription |
No | Legacy compatibility flag (deprecated) |
transcription.sticky |
No | Legacy compatibility flag (deprecated) |
dataDir |
No | Override user data directory (see below) |
context |
No | Per-project context overrides (see Context) |
commands |
No | Toggle and configure built-in/system commands (see Commands config and System commands) |
The project key (the key in the projects map) is the single source of identity and is used in log/data paths. It is not derived from name or cwd. You may still see this key referred to as slug in code and internal fields — treat slug as legacy naming.
| Value | Resolved Path |
|---|---|
| (empty) | {project-cwd}/.hal/users |
~ |
{config-dir}/.hal/{project-key}/data |
Relative path (e.g. .mydata) |
{project-cwd}/{value} |
| Absolute path | Used as-is |
Log file paths and options are documented in Logging.
With a config at ~/workspace/hal.config.yaml (or .json / .jsonc):
~/workspace/
├── hal.config.yaml (or .json / .jsonc)
├── hal.config.local.yaml (or .json / .jsonc — gitignored, local overrides / secrets)
├── .hal/
│ ├── hooks/
│ │ └── context.mjs (global context hook, optional)
│ ├── commands/
│ │ └── mycommand.mjs (global command, available to all projects)
│ └── logs/
│ ├── backend/
│ │ └── 2026-02-26.txt (when persist: true)
│ └── frontend/
│ └── 2026-02-26.txt
├── .env (gitignored — local secrets, do not commit)
├── .env.local (optional gitignored override)
├── backend/
│ ├── CLAUDE.md
│ ├── .claude/
│ │ ├── settings.json
│ │ └── skills/
│ │ └── deploy/
│ │ └── SKILL.md (skill exposed as /deploy command)
│ └── .hal/
│ ├── hooks/
│ │ └── context.mjs (project context hook, optional)
│ ├── commands/
│ │ └── deploy.mjs (project-specific command, optional)
│ └── users/
│ └── {userId}/
│ ├── uploads/ # Files FROM user (to engine)
│ ├── downloads/ # Files TO user (from engine)
│ └── session.json # Session data
└── frontend/
├── CLAUDE.md
└── .hal/
└── users/
| Topic | Description |
|---|---|
| Env files | Env file loading, wizard file selection, custom env path, .gitignore |
| Session | Session mode: false | true | "shared" | "user"; per-engine support and boot errors |
| Context | Context injection — implicit keys, custom context, variable patterns, hooks |
| Commands | Built-in command config — /start, /help, /reset, /clear, /model, /engine, /git |
| Logging | Log level, flow, persist, log file paths |
| Rate limit | Max messages per user per window (max, windowMs) |
| Engines | Supported engines, engine config, model list, model defaults, per-engine setup |
{ "globals": { "engine": { "name": "claude" }, "access": { "allowedUserIds": [123456789] }, }, "projects": { "mybot": { "telegram": { "botToken": "${BOT_TOKEN}" }, }, }, }