Lifecycle Hooks
Swival can run a user-configured command at two points in every session: once at startup (before memory and continue state are loaded) and once at exit (after history, reports, and continue files are written). This lets you sync .swival/ state to and from remote storage without any provider-specific code in Swival itself — and without committing .swival/ to git. Memory, continue files, and reports stay out of version control but still follow you across machines.
The feature is generic. Swival does not know or care what the hook does. The canonical use case is syncing .swival/ to Hugging Face Buckets so that memory, continue files, and reports survive across machines and commits — but any storage backend works.
Enabling Hooks
On the command line:
swival --lifecycle-command "./scripts/sync.sh" "task"
In a config file:
lifecycle_command = "./scripts/sync.sh"
lifecycle_timeout = 300
lifecycle_fail_closed = false
In the library API:
from swival import Session
with Session(
lifecycle_command="./scripts/sync.sh",
lifecycle_timeout=300,
lifecycle_fail_closed=False,
lifecycle_enabled=True,
) as s:
result = s.run("task")
The value is a shell command string. It is split with shlex.split, and path-like first tokens — anything starting with /, ~, or containing a / (e.g. ./, ../, .rtk/, scripts/) — resolve against the config file's parent directory, consistent with reviewer, llm_filter, and other command-valued config keys.
Command Invocation
Swival calls the configured command as:
<command> startup <base_dir>
<command> exit <base_dir>
The first positional argument is the event name (startup or exit). The second is the absolute path to the project base directory. The working directory is set to base_dir.
The command is validated at startup using the same logic as reviewer and llm_filter: shlex.split parses the string, and the first token must be an executable on PATH or a path to an executable file. No shell=True — the command runs as a direct subprocess.
Execution Ordering
Startup
The startup hook runs after:
- Config file loading and CLI flag merging
- Provider and model resolution
- Git metadata discovery
.swival/directory creation
It runs before:
- Memory loading into the system prompt
- Continue-file resume checks
- REPL startup that depends on
.swival/content
This ordering is the whole point: a startup hook can download files into .swival/memory/ or .swival/continue.md and Swival will pick them up as if they were already there.
Exit
The exit hook runs after:
- The final answer is produced (or the run outcome is known)
.swival/HISTORY.mdis appended- The report file is written (if
--reportis set) - The continue file is written (on interruption paths)
In the CLI, the exit hook runs in the outer finally block, so it fires on success, exhaustion, error, and interruption.
REPL
Hooks run once on process start and once on process exit. They do not run per prompt, on /clear, or on /continue.
Reviewer and Serve Modes
Hooks are not run in --reviewer-mode or --serve. Reviewer mode is a subprocess of the main Swival invocation — running hooks there would cause nested syncs. Serve mode manages per-request sessions with different lifecycle semantics that will be addressed separately.
Environment Variables
The hook receives all of the parent environment plus these SWIVAL_* variables:
| Variable | Description | Events |
|---|---|---|
SWIVAL_HOOK_EVENT |
startup or exit |
both |
SWIVAL_BASE_DIR |
Absolute path to the project base directory | both |
SWIVAL_SWIVAL_DIR |
Path to the .swival/ directory |
both |
SWIVAL_PROVIDER |
Provider name (e.g. lmstudio, openrouter) |
both |
SWIVAL_MODEL |
Resolved model ID | both |
SWIVAL_GIT_PRESENT |
1 if inside a Git repo, 0 otherwise |
both |
SWIVAL_REPO_ROOT |
Git repo root (absolute path) | both |
SWIVAL_PROJECT_REL |
Relative path from repo root to base_dir (empty at repo root) | both |
SWIVAL_GIT_HEAD |
HEAD commit SHA | both |
SWIVAL_GIT_DIRTY |
1 if working tree has staged, unstaged, or untracked changes |
both |
SWIVAL_GIT_REMOTE |
remote.origin.url value |
both |
SWIVAL_REPO_HASH |
48-character hash of normalized repo identity | both |
SWIVAL_PROJECT_HASH |
48-character hash of repo identity + project_rel | both |
SWIVAL_REPORT |
Path to the report file | exit only |
SWIVAL_OUTCOME |
Run outcome: success, exhausted, error, interrupted |
exit only |
SWIVAL_EXIT_CODE |
Process exit code as a string | exit only |
Git-specific variables are omitted when SWIVAL_GIT_PRESENT=0.
SWIVAL_REPO_HASH and SWIVAL_PROJECT_HASH are derived from the normalized remote URL (SSH and HTTPS forms collapse to the same value). When there is no remote, the local repo root path is used instead. Two repos cloned from the same remote get the same repo hash regardless of where they live on disk.
SWIVAL_PROJECT_HASH further incorporates SWIVAL_PROJECT_REL, so different subprojects in a monorepo get distinct hashes.
Failure Semantics
By default, hooks fail open. A nonzero exit code, a timeout, or a spawn failure is silently ignored and the run continues normally. In verbose mode (--quiet not set), a warning is printed to stderr. This means a broken sync script will not prevent you from using Swival.
Fail-Closed Mode
Pass --lifecycle-fail-closed (or lifecycle_fail_closed = true in config) to make hook failures fatal:
- A startup failure raises an error before the agent loop starts.
- An exit failure forces a nonzero process exit after cleanup completes (CLI) or raises
LifecycleError(library API).
In the library API, LifecycleError propagates from Session.run(), Session.close(), and Session.__exit__() (when no other exception is active).
Kill Switch
Pass --no-lifecycle to disable hooks entirely. This is useful for nested invocations, CI jobs, or debugging.
Library API
Session supports the same lifecycle semantics as the CLI:
from swival import Session
# Single-shot: exit hook runs automatically after run()
result = Session(lifecycle_command="./sync.sh").run("task")
# Multi-turn: exit hook runs on close() or __exit__
with Session(lifecycle_command="./sync.sh") as s:
s.ask("first question")
s.ask("follow-up")
# exit hook fires here
# Explicit close without context manager
s = Session(lifecycle_command="./sync.sh")
s.ask("question")
s.close(outcome="success", exit_code=0)
For single-shot run(), the exit hook fires after local artifacts are written, even if the agent loop raises an exception. The outcome and exit_code passed to the hook reflect what actually happened.
For multi-turn ask(), the exit hook fires when the session is closed — either via close() or __exit__. It does not fire after each ask() call. This matches REPL semantics: one startup, one exit.
Hugging Face Buckets
HF Buckets provide S3-like object storage on the Hugging Face Hub. Combined with lifecycle hooks, they give Swival commit-scoped remote state with no HF-specific code in Swival itself.
Note that HF Buckets are not end-to-end encrypted. Files are encrypted in transit (TLS) and at rest on HF's servers, but Hugging Face itself can access the stored data. If your .swival/ state contains sensitive information, encrypt it before syncing.
Bucket Layout
The recommended layout uses one stable bucket per repo identity and commit-specific prefixes inside it:
hf://buckets/<namespace>/swival-<repo_hash>/
<project_rel>/heads/<head_sha>/
memory/
MEMORY.md
...
continue.md
reports/
...
This keeps bucket count bounded (one per repo, not one per commit), isolates monorepo subprojects via project_rel, and still gives commit-scoped remote state through the heads/<sha> prefix.
Example Sync Script
#!/bin/sh
# scripts/swival-hf-sync.sh
set -e
EVENT="$1"
BASE_DIR="$2"
SWIVAL_DIR="$BASE_DIR/.swival"
# Skip if not in a Git repo
[ "$SWIVAL_GIT_PRESENT" = "0" ] && exit 0
# Resolve HF namespace (cache in env to avoid repeated calls)
HF_NS="${HF_NS:-$(hf auth whoami --format json 2>/dev/null | grep '"name"' | head -1 | sed 's/.*"name": *"//;s/".*//')}"
[ -z "$HF_NS" ] && exit 0
BUCKET="swival-$SWIVAL_REPO_HASH"
# Build prefix: project_rel/heads/<sha> or just heads/<sha> at repo root
if [ -n "$SWIVAL_PROJECT_REL" ]; then
PREFIX="$SWIVAL_PROJECT_REL/heads/$SWIVAL_GIT_HEAD"
else
PREFIX="heads/$SWIVAL_GIT_HEAD"
fi
REMOTE="hf://buckets/$HF_NS/$BUCKET/$PREFIX"
if [ "$EVENT" = "startup" ]; then
mkdir -p "$SWIVAL_DIR"
hf buckets sync "$REMOTE" "$SWIVAL_DIR" \
--include "memory/**" \
--include "continue.md" \
--include "reports/**" \
2>/dev/null || true
elif [ "$EVENT" = "exit" ]; then
hf buckets sync "$SWIVAL_DIR/" "$REMOTE" \
--include "memory/**" \
--include "continue.md" \
--include "reports/**" \
--exclude "cache.db" \
--exclude "trash/**" \
--exclude "cmd_output_*" \
2>/dev/null || true
fi
Make the script executable and configure it:
lifecycle_command = "./scripts/swival-hf-sync.sh"
What Gets Synced
The example script syncs:
memory/**— auto-memory entries that accumulate across sessionscontinue.md— interrupted session resume statereports/**— evaluation reports
It excludes:
cache.db— LLM response cache (machine-local, large)trash/**— soft-deleted files (local safety net only)cmd_output_*— temporary large command outputs (auto-deleted after 600 seconds)
Why Repo Hash Instead of Repo Name
Git remotes come in multiple forms:
git@github.com:org/repo.githttps://github.com/org/repo.gitssh://git@github.com/org/repo.git
Swival normalizes all of these to github.com/org/repo before hashing, so the same repo gets the same bucket regardless of how it was cloned. The 48-character (192-bit) SHA-256 prefix is collision-resistant.
Commit Scoping
Each heads/<sha> prefix represents the state of .swival/ at a particular commit. When you switch branches or rebase, the HEAD changes and Swival naturally starts syncing to a different prefix. Previous prefixes remain in the bucket as historical snapshots.
This is intentional: if you switch back to a commit you worked on before, the hook pulls the memory and continue state from that exact point.
Purging State
Because the layout uses one stable bucket per repo, cleanup is straightforward.
Delete all Swival state for a repo (replace <namespace> with your HF username):
hf buckets delete "<namespace>/swival-$(echo -n 'github.com/org/repo' | shasum -a 256 | cut -c1-48)"
To derive the full bucket path for a local repo:
cd /path/to/repo
HF_NS=$(hf auth whoami --format json 2>/dev/null | grep '"name"' | head -1 | sed 's/.*"name": *"//;s/".*//')
REMOTE=$(git config --get remote.origin.url)
# Normalize: strip .git suffix, convert SSH to path form
NORM=$(echo "$REMOTE" | sed 's/\.git$//' | sed 's|^git@|ssh://git@|' | sed 's|ssh://[^@]*@||' | sed 's|https\?://||' | sed 's|:|/|')
HASH=$(echo -n "$NORM" | shasum -a 256 | cut -c1-48)
echo "$HF_NS/swival-$HASH"
For a single subproject in a monorepo, remove only its prefix path from the bucket rather than the entire bucket.
Dirty Worktrees
SWIVAL_GIT_DIRTY reports 1 when the working tree has staged changes, unstaged modifications, or untracked files. A sync script could use this to branch its behavior — for example, syncing to a dirty/ prefix instead of heads/<sha>/ when the tree is dirty, so that uncommitted work does not pollute the clean commit prefix.
The example script above does not do this. It always syncs to heads/<sha>, which means dirty-tree sessions overwrite the same prefix as clean-tree sessions for that commit. For most workflows this is fine. Add the dirty prefix if you need strict isolation.
Interaction with Other Features
Reviewer and self-review: Hooks do not run in --reviewer-mode. Self-review spawns a reviewer subprocess, but that subprocess gets --reviewer-mode, so it also skips hooks. No nested sync.
A2A serve: Hooks are excluded from --serve in this release. Per-session hooks for long-lived A2A servers need different lifecycle semantics and will be addressed separately.
AgentFS sandbox: Hooks run in the same process context as Swival. If you are running inside an AgentFS sandbox, the hook script needs network access to reach the remote storage backend.
Reports: Lifecycle events (startup and exit) are recorded in the report timeline when --report is set. Each event includes its exit code, duration, and any error message. A fail-closed exit hook failure rewrites the report with outcome: "error".