A small CLI that injects a fixed identity and voice into an AI coding agent's session context at startup. It's guaranteed loaded, not left as a pointer the agent might or might not follow.
Coding agents are usually told who to be through a chain of pointers: a
system prompt says "read this file," which says "read that file." Each
hop is optional from the model's perspective: it can be skipped,
deprioritized, or missed under context pressure. psyche collapses that
chain: point it at your identity/voice file(s) once, and it prints that
content directly into the session's context, verbatim, every time.
psyche init claude # or: opencode, codex, cursor, hermes
psyche init # auto-detect installed harnesses, register allWires psyche into that harness's own hook mechanism (see below), and
scaffolds ~/.config/psyche/{config.json,SOUL.md,quirks.d/} with a
starting voice and a handful of example quirks. It's
idempotent: safe to run again later, and never overwrites anything
already there. Edit SOUL.md/quirks.d/config.json freely once
they exist; init won't touch them again. <harness> is looked up in
a small registry (internal/harness), so adding a new agent tool is
one more registration.
psyche init clauderegisterspsycheas a Claude CodeSessionStarthook in~/.claude/settings.json(user scope, every project, not shared with a team) with an in-place JSON edit that leaves the rest of the file untouched.psyche init opencodeinstalls a global opencode plugin at~/.config/opencode/plugin/psyche.js, which shells out topsycheand injects its output into the system prompt via opencode'sexperimental.chat.system.transformhook. This is invoked on every system-prompt build rather than once per session (opencode has no reliable session-start hook), which is safe here specifically because the quirk roll is seeded by calendar day: repeated calls the same day are idempotent, so there's nothing to de-duplicate.psyche init codexappends a SessionStart hook to~/.codex/config.toml(existing content, comments included, is left byte-for-byte intact). Codex holds new hooks inert until you trust them via its/hooksprompt;initnever grants that for you.psyche init cursorappends a sessionStart hook to~/.cursor/hooks.json. Unverified: written from Cursor's docs without an install to test against; see the header ofinternal/harness/cursor.go.psyche init hermesappends apre_llm_callhook to~/.hermes/config.yaml(comments preserved). Output is gated to the session's first LLM call, and Hermes prompts for consent on first use of the hook.
psyche [--format <claude|opencode|codex|cursor|hermes>] [--separator SEP]
[--event NAME] [--config PATH] [--quirks-dir PATH]
[--quirk-none-weight N] [--quirk-seed N] [FILE...]Files are read in the order given and concatenated (separated by
--separator). At least one file must be resolved, from positional
args, PSYCHE_FILES, or a config file's files list, or psyche
exits with an error.
Output formats are looked up in a small registry (internal/format)
rather than a hardcoded switch, so --format's allowed values and
error text are always derived from what's actually registered:
-
claudeprints Claude Code's hook JSON to stdout:{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"..."}}Wire it up as a hook command, e.g.
psyche --format claude ~/.config/psyche/SOUL.md. The hook event name defaults toSessionStartbut is configurable via--event(see below). Claude Code'sadditionalContextmechanism also works on other hooks, e.g.UserPromptSubmit. -
opencodeprints the concatenated content as plain text, with no envelope.psyche init opencode(see Setup, above) sets up a plugin that callspsycheas a subprocess and pushes its stdout ontooutput.systeminside opencode'sexperimental.chat.system.transformhook. opencode has no direct equivalent of Claude Code'sSessionStarthook, so this is the mechanism a plugin has to use instead. -
codexprints the same envelope asclaude: Codex's SessionStart hook contract matches Claude Code's. -
cursorprints{"additional_context": "..."}— a top-level field rather than Claude's nested shape. -
hermesprints{"context": "..."}, and only on a session's first LLM call (read from the hook's stdin payload); later turns get nothing.
A missing or unreadable soul file prints a warning to stderr and is
skipped; psyche always exits 0, even if every file failed to read:
a broken identity source should never break session start.
A soul file is markdown, injected verbatim, optionally preceded by a YAML frontmatter block that controls how the file is used without itself being injected:
---
name: Core Identity
description: The agent's fixed voice and values.
enabled: true
priority: 10
tags: [voice, values]
version: "1.0"
---
Be curious and kind. Speak plainly, and say when you don't know.| Field | Type | Default | Effect |
|---|---|---|---|
name |
string | (none) | A label, used in warnings/logs when multiple souls are in play. |
description |
string | (none) | Documentation only, not otherwise used. |
enabled |
bool | true |
false skips the file entirely without deleting/renaming it. |
priority |
int | 0 |
Higher sorts first across multiple soul files, ties keep arg order. |
tags |
[]string |
(none) | Freeform labels, for your own organization. |
version |
string | (none) | Documentation only, not otherwise used. |
Frontmatter is optional. A plain file with no --- block is
still valid (priority 0, enabled). A
frontmatter block that opens with --- but never closes prints a
warning to stderr and falls back to treating the whole file, delimiter
included, as the body: malformed metadata never drops identity
content.
See examples/souls/ for copy-paste starters
(full frontmatter, no frontmatter, priority, enabled: false).
| Flag | Env var | Config field | Default |
|---|---|---|---|
--format |
PSYCHE_FORMAT |
format |
(required, no built-in default) |
--separator |
PSYCHE_SEPARATOR |
separator |
"\n\n" |
--event |
PSYCHE_EVENT |
event |
"SessionStart" |
--config |
PSYCHE_CONFIG |
(n/a) | <user config dir>/psyche/config.json |
--quirks-dir |
PSYCHE_QUIRKS_DIR |
quirksDir |
<user config dir>/psyche/quirks.d |
--quirk-none-weight |
PSYCHE_QUIRK_NONE_WEIGHT |
quirksNoneWeight |
0 |
--quirk-seed |
PSYCHE_QUIRK_SEED |
(n/a) | today's date, hashed |
| (positional) | PSYCHE_FILES |
files |
(required, no built-in default) |
For every setting except the config/quirks file locations themselves, precedence is: **command-line flag > environment variable > config file
built-in default**, resolved independently per setting.
PSYCHE_FILESis a list, delimited byos.PathListSeparator(:on Unix,;on Windows; chosen over a literal:so it doesn't collide with Windows drive-letter paths likeC:\...).
The config file's own location is a simpler, separate chain:
--config flag > PSYCHE_CONFIG env > the default location above.
A missing config file (or quirks directory) is silent: it's just not used. A config file that exists but is unreadable or malformed prints a warning to stderr and falls back to defaults for that layer, the same "never break session start" rule as a missing soul file.
{
"format": "claude",
"files": ["/home/me/.config/psyche/SOUL.md"],
"separator": "\n\n",
"event": "SessionStart",
"quirksDir": "/home/me/.config/psyche/quirks.d",
"quirksNoneWeight": 5
}Default location: ~/.config/psyche/config.json on Linux,
~/Library/Application Support/psyche/config.json on macOS,
%AppData%\psyche\config.json on Windows (via os.UserConfigDir()).
An optional pool of small behavioral traits, one quirk per file (same
idea as examples/souls/, or a conf.d-style directory: dropping in a
new file is the whole workflow, no list to edit). On each run, psyche
reads every .md file in the quirks directory, rolls one (or none),
and appends it as one more piece of injected content, alongside the
soul files:
---
weight: 2
---
Blame any flaky test on Mercury being in retrograde.- Frontmatter is optional; a quirk file can be a single plain-prose
line with no
---block at all. weightis relative and optional; omitted or<= 0counts as1, so a directory with no weights specified rolls uniformly across files.--quirk-none-weight(envPSYCHE_QUIRK_NONE_WEIGHT, configquirksNoneWeight) is extra weight mass reserved for rolling no quirk at all, a pool-level setting not tied to any one quirk file (default0, meaning a quirk is always rolled if the directory is nonempty).- The roll is seeded by calendar day (not per-session), so the same
quirk holds across every
psycheinvocation on a given day, regardless of output format. Override with--quirk-seed/PSYCHE_QUIRK_SEEDfor testing or to pin a specific outcome. - No quirks directory resolving (the default, until you create one) means the feature is a no-op: nothing is appended.
See examples/quirks/ for copy-paste starters.
Building from source is covered in CONTRIBUTING.md.