Adds an LLM-driven texting layer to the in-game phone: supported contacts answer with generated dialogue instead of scripted messages, with awareness of quest state.
Other mods can take ai_npc as an optional dependency: @if(ModuleExists("AiNpc")) lets a
single build gain an AI voice where ai_npc is installed and do without where it is not. See
docs/API.md.
Install these first (all are standard framework mods):
| Dependency | Purpose |
|---|---|
| RED4ext | plugin loader |
| redscript | compiles this mod's .reds at launch |
| Codeware | UI + input callbacks |
| RedHttpClient | HTTPS calls to OpenRouter |
| RedData | JSON parsing |
| RedFileSystem | reads/writes settings + history |
| Mod Settings | in-game settings menu |
All seven are hard requirements: the mod calls into each one from redscript, so a missing framework is a compile error at launch, not a degraded feature.
Four more are optional, each unlocking one thing and inert when absent:
| Optional | Unlocks |
|---|---|
| Cyber Engine Tweaks | the in-game window shipped in the archive (bin\x64\plugins\cyber_engine_tweaks\mods\ai_npc_debug\): Setup — enter and test your API key, switch provider, no restart — and Journal — see Importing a lost history |
| the Claude CLI, signed in | the Claude CLI provider — replies come from your existing subscription instead of a metered API key; see The CLI lanes |
| the Codex CLI, signed in with ChatGPT | the Codex CLI provider — the same trade on a ChatGPT subscription; see The CLI lanes |
| BrowserExtension | the chat as a website on every in-game computer — a second surface beside the phone. Every declaration that touches it carries @if(ModuleExists(...)) (src\r6\scripts\ai_npc\AiNpcTerminalSite.reds), so the mod compiles and runs identically without it |
Phone Extension Framework is not a dependency. ai_npc coexists with it (its contact
wrapper is deliberately sorted after it — see src\r6\scripts\zzz_ai_npc_phone\) and exposes
AiNpcContactHash for mods that do use it, but nothing here calls into it.
OpenRouter is the supported lane, and the only one meant for playing. Each player brings
their own key, the mod intermediates nothing, and at the measured pace it costs cents a month
(docs/MODEL_COSTS.md). The subscription lanes are there so that people writing characters
and add-ons can test against a real model without paying per message — playing through them is
outside what a coding-agent plan is sold for, and either provider may act on the account. The
reasoning, with the citations, is in docs/DISTRIBUTION.md.
The packaged archive carries a FOMOD installer, so Vortex and MO2 ask the one question
the archive cannot answer for itself: which provider the characters should use. The answer
travels as a file (presets\<Provider>\...\AiNpcInstallPreset.reds, generated by
tools\package.ps1) and is applied once, on the first session — after that the game's own
settings win, so a choice made in the menu is never overruled by an installer choice from
three weeks ago.
Everything else is decided from that answer rather than asked again. The CET window is always
installed, because it is where the key is entered and it is inert without CET; the RED4ext
plugin (ai_npc.dll) carries the CLI lanes. Framework requirements are not checked here — Nexus lists them on the mod page, which
is where Vortex reads them.
The installer does not ask for your API key. A FOMOD has no text field: it can only decide which files get copied. The key is entered in game instead, in the CET overlay, where it can be tested against the provider and changed without restarting — see The setup window.
Installing by hand, or with a manager that ignores the FOMOD, copies the contents of src/
into your Cyberpunk 2077 folder, so you end up with:
Cyberpunk 2077\r6\scripts\ai_npc\*.reds
Or unzip the packaged release (tools\package.ps1, see below) at the game root.
On first launch the mod creates these files in Cyberpunk 2077\r6\storages\AiNpc\:
settings.json— API keys and model selection (edit this)journal.index.json+journal.b<N>.jsonl— chat history, tied to your savegames (see Conversation storage)characters.example.json— schema reference for character overrides, rewritten every launchfacts.example.json— schema reference for the quest-fact bridge, rewritten every launchconfig-report.json— what the character/prompt config loaded and rejected, rewritten every launch
See Customising characters and prompts for the optional files you can add next to them.
Basis: 38 messages an hour, measured over 15 real sessions. 2 h/day = 2 280 messages a
month. OpenRouter prices read 2026-08-28, cached where the provider discounts a prefix.
Measurements: docs/MODEL_BENCH.md.
| model | 1 000 messages | 2 h/day | messages for 1 $ | reply | defects /54 | Normal tier /24 | pick |
|---|---|---|---|---|---|---|---|
| qwen/qwen3-235b-a22b-2507 | 0.24 $ | 0.54 $ | 4 200 | 2.7 s | 0 | 23 | default |
| deepseek/deepseek-v4-flash | 0.26 $ | 0.60 $ | 3 800 | 4.6 s | 1 | 14 | |
| meta-llama/llama-4-maverick | 1.13 $ | 2.57 $ | 890 | 2.0 s | 1 | 1 | if you lower the content level |
| deepseek/deepseek-chat-v3-0324 | 1.41 $ | 3.21 $ | 710 | 3.7 s | 1 | 4 | fallback |
| google/gemma-4-31b-it:free | 0 $ | 0 $ | — | 2.0 s | 0 /34 | not measured | 46 of 54 requests: HTTP 429 |
defects /54 — replies out of 54 carrying a name prefix, an emoji, an invented timestamp, a
control token or markdown. Normal tier /24 — explicit replies out of 24 on an already
explicit thread, lower is better: 23 means the Normal content level is ignored.
Open the CET overlay: AI NPC, tab Setup. It needs Cyber Engine Tweaks, and it is the short way through everything the next section describes by hand.
- what you are on — provider, model, whether a key is present (masked: enough to recognise it, never enough to reuse it), and whether the mod considers itself usable.
- provider — four buttons. The switch applies to the next message immediately, and is pushed into Mod Settings so it survives a restart. If Mod Settings will not take it, the window says so rather than letting you find out at the next launch.
- the fields that matter for that provider — key, model, routing, endpoint. Saving writes
settings.jsonand the running game picks it up at once. Only the keys listed below can be written, so a typo cannot invent a setting that nothing reads. - Test connection — sends one real request, built by the same two functions that build a
character's reply, and prints what came back: the model's answer, or the provider's own
error, or the explanation for a transport failure (the
-no-tlstrap, and the RedHttpClient version that goes with it).
The same four things are on the console, for when the overlay is not the fastest way there — though a key typed there stays in the CET scrollback, which the window's masked box avoids:
print(GetMod("ai_npc_debug").setup())
print(GetMod("ai_npc_debug").provider("OpenRouter"))
print(GetMod("ai_npc_debug").set("openRouterApiKey", "sk-or-v1-..."))
print(GetMod("ai_npc_debug").test())Everything below can be done from the setup window above. It is written out because the file is the source of truth, because a text editor works without CET, and because this is what you will be asked to check when something does not work.
r6\storages\AiNpc\settings.json:
{
"openRouterApiKey": "",
"openRouterModel": "google/gemma-4-31b-it:free",
"openRouterProvider": "Auto",
"claudeCliModel": "sonnet",
"claudeCliPath": "",
"codexCliModel": "",
"codexCliPath": "",
"appearance": "",
"playerDescription": "",
"importConversationsFrom": ""
}-
OpenRouter — needs a key from https://openrouter.ai/keys; free model ids end in
:free.openRouterProvideris"Auto"or a provider slug such as"google-ai-studio". The shipped default is free and works, but a free model is throttled by whoever serves it: measured on 2026-08-28, it answered 46 requests out of 54 with "rate-limited upstream". It is a way to try the mod, not a way to play an evening. For that,qwen/qwen3-235b-a22b-2507(~0.55 $/month at two hours a day) is the cheapest that does the job: it emits the appointment commands 18 times out of 20 on real recorded conversations, with no mechanical defect over 54 replies. What it does not do is respect the Normal explicitness tier — leave the mod on its top tier, or pickmeta-llama/llama-4-maverick(~2.56 $/month), which holds that tier better than anything measured and takes every appointment, 20 out of 20.deepseek/deepseek-chat-v3-0324(~3.21 $/month) matches Maverick and is the fallback when a provider outage makes it unavailable. Do not pick on price alone: a model can write a perfect reply and silently leave the command out, and nothing in the game will tell you. Ten models were measured against four criteria — see docs/MODEL_BENCH.md, and docs/MODEL_COSTS.md for what an hour of play costs. -
Claude CLI — no key, ever. Runs the
claudecommand you are already signed into, throughai_npc.dll.claudeCliModelis a CLI alias (haiku,sonnet,opus);claudeCliPathis only needed whenclaudeis not on the PATH the game inherited. See "The CLI lanes" below. -
Codex CLI — the same idea on a ChatGPT subscription, through the
codexcommand and the same DLL.codexCliModelis a model id (empty lets the CLI use its own default);codexCliPathis only needed whencodexis not on the PATH the game inherited. The lane needscodex login statusto report a ChatGPT sign-in: an API key is refused rather than billed. Two caveats, both stated rather than discovered in play — it has never been run in game, and the safe-for-work tier's measurement was taken on the Claude lane, where the tier text is a real system prompt. Codex has no system prompt, so that number does not transfer. -
memory — no longer here. Whether a character remembers conversations that have scrolled out of its window, and how much it keeps, are two settings in Mod Settings → AI NPC → Memory (2026-08-22). A
memoryEnabledkey left in this file is ignored, and startup logs one line saying so. See docs/MEMORY.md. -
appearance— what V looks like, in your own words: tattoos, chrome, scars, hair, the jacket they never take off. Empty by default, and then the mod says nothing about V's looks at all. What you write is added to what the game already knows — V's life path, read from the save, and V's gender, read from the character — not instead of it. It goes in verbatim, so write a sentence, not a list of keywords.This lives in the file rather than in the menu because Mod Settings has no text field (booleans, numbers and dropdowns only). It replaced four dropdowns — perceived origin, hair, build, breast size — which could name a hair colour and could not name a tattoo.
-
playerDescription— the same description, but replacing the whole section instead of adding to it, life path included. Empty by default. This is the escape hatch for a mod or a player who needs the mod to stop stating anything of its own about V; for normal use,appearanceis the one you want. -
importConversationsFrom— a repair hatch, empty except when you are using it: a journal pointer such as"b14:467", which the next save you load adopts as its own conversation history. For when a save is lost and play resumes from an earlier one. See "Importing a lost history" below, and delete the key once it has run. -
installPreset— written by the mod, not by you: the provider the FOMOD installer was told to use, recorded once it has been applied. It is what stops the installer's answer from being re-applied over a choice you made later in the menu. Deleting it makes the next launch apply the installer's choice again.
Keys are never stored in the scripts, so the packaged mod can be shared safely.
Everything else (provider choice, tone, memory, logging) lives in the in-game Mod Settings → AI NPC menu.
Off by default. A failed message always shows the same in-fiction line -- the Night City
carrier is unreachable, call support -- which keeps the chat window diegetic and tells you
nothing. With Debug Mode on, a second message follows it in the same conversation with
the provider, the url and the cause: the same three things FTLogError writes to the log,
put where the failure happened instead of in a file you have to know about. The technical
message is never translated, never written to the conversation history, and never sent to
the model -- the character did not say it.
With Enable Logs on, every request writes one line to the CET console when its answer lands -- the reply itself, the retry that repairs a broken command, and the compaction that maintains a character's memory in the background:
[ai_npc]: request lane=speaking contact=panam provider=OpenRouter model=openai/gpt-oss-120b
chars_system=16321 chars_user=853 status=200 prompt_tokens=4504 completion_tokens=312
total_tokens=4816 finish=stop chars_per_token=3.81 charged=4816 day_total=48160
Metadata only: no prompt, no reply, nothing either party wrote. That is what makes it safe to
paste into a bug report, and it is what a free provider actually meters -- see
docs/PROMPT_BUDGET.md, which turns these numbers into messages per day.
Three things are worth knowing about the line. lane=thinking is the mod compacting memory
on its own initiative, and it spends the same daily budget as everything the player types.
usage=absent means the provider reported nothing, not that the request was free. And
finish=length means the reply was cut short by a token limit rather than the character
choosing to stop -- which is the difference between a model ignoring a command and a command
that never got written.
With Debug Mode on as well, each request and reply is also dumped in full. That log then contains the conversation, so it is for hunting a malformed body, not for leaving on.
Off by default: the mod sends as much as you ask it to. Switched on, in Mod Settings → AI NPC → Budget, it stops sending once the day's tokens are spent and says so in the chat -- as a data allowance that ran out, not as an error, because that is what it is.
The count covers every save at once. It lives in r6/storages/AiNpc/usage.json, outside
the savegame, because a provider meters one key and does not care which playthrough spent it.
Everything counts against it: the replies, the retry that repairs a broken command, and the
compaction that maintains memory in the background. There is no exemption for a local model
-- one rule, one tally. If you run one, leave the limit off.
"Day" is the provider's own UTC day, read from the Date header of its answers, since that is
the clock its quota actually resets on. One consequence worth knowing: the first message of
each session always goes through, even on a spent day -- it is the answer to that message
that tells the mod what day it is. If the day has turned, the count starts again from zero.
The default is 200 (thousand tokens), which is the free tier measured on Groq: about forty
messages, or twenty-five once characters remember things. At four fifths of the budget the
network sends one warning, once a day. See docs/TOKEN_BUDGET.md.
Six things it no longer asks about. Most of them because the game already answers them -- a setting the game can answer is a setting the player can get wrong, and a wrong answer is stated as fact to every character, in every playthrough. The last one because a dropdown was the wrong shape for the question:
| Was a setting | Now answered by |
|---|---|
| Romance: Panam / Judy / River / Kerry | the save's romance facts (sq027_panam_lover, sq030_judy_lover, sq029_river_lover, sq028_kerry_relationship) |
| Romance: Songbird / Rogue / Viktor / Takemura | removed — the base game has no romance for them |
| Character | the contact you open on the phone |
| Gender | the character V was created with (GetResolvedGenderName) |
| Language | the game's own text language (/language in the game options) |
| V's appearance: origin / hair / build / breast size | nothing -- four closed lists could name a hair colour and not a tattoo. Replaced by the free-form appearance key in settings.json, above |
The chat UI and the generated replies follow that language. A locale the mod ships no prompt text for -- Japanese, Polish, Chinese -- falls back to English rather than to silence: an English instruction produces an English reply, no instruction produces whatever the model feels like. The Mod Settings menu itself stays in English: its labels are compile-time annotations and Mod Settings has no localisation for them.
Two of the three providers do not call an API at all: they run a command-line tool you are already signed into, so the replies are billed against a subscription you already pay for rather than per token.
Cyberpunk (REDscript) --> ai_npc.dll --CreateProcess--> claude -p
codex exec
REDscript cannot spawn a process, which is why this needs native code. The plugin
(red4ext\plugins\ai_npc\ai_npc.dll) runs the CLI on a worker thread, reads what it wrote,
and hands the reply back to the mod in the same shape an HTTP provider would have returned.
Pick Claude CLI or Codex CLI as the model in Mod Settings.
No Python, no port, no launch argument. An earlier version reached the CLI through a
local HTTP bridge, because the mod could only speak HTTP. That cost four things a player had
to get right — RedHttpClient's version, -no-tls, Python on the PATH, and a free port 8787 —
and all four failed with the same message. None of them applies any more.
If you set -no-tls for the old bridge, remove it. It does not permit http:// alongside
https://; it turns TLS off, and OpenRouter is now the only lane that uses HTTPS. Left in
place it breaks that lane silently, with the same [NO SIGNAL: HTTP 0] it was added to fix.
claude installed and signed in. The plugin checks with claude auth status --json before
the first request of each session, and refuses two states outright rather than proceeding:
- not signed in — it says to run
claude login; - signed in, but with an API key — the lane exists to spend a subscription, and a key
would bill you per message for what that subscription covers. If
ANTHROPIC_API_KEYis set in your environment it is removed from what the CLI inherits; if the CLI still reports one, the request is refused and the variable is named.
loggedIn alone is not the check, and that is not caution: measured on Claude Code 2.1.241,
claude auth status --json with ANTHROPIC_API_KEY set still reports "loggedIn": true and
"authMethod": "claude.ai". What proves a subscription is apiKeySource being absent and
subscriptionType being non-null.
If claude is not on the PATH the game inherited — the native installer puts it in
%USERPROFILE%\.local\bin, and a PATH change made while the game is running is not visible
to it — set claudeCliPath in settings.json to the full path.
codex installed and signed in with ChatGPT. The plugin runs codex login status before
the first request of each session and reads what it printed:
Logged in using ChatGPT— the lane runs;Logged in using an API key— refused, with the way out named (codex logout, thencodex login). Same reason as the Claude lane: a key bills you per message for what the subscription already covers;Logged in using Agent Identity— refused; it bills an organisation rather than a subscription;Not logged in, or anything the plugin cannot recognise — refused. A check that cannot be read is never treated as a pass.
CODEX_API_KEY, CODEX_ACCESS_TOKEN, OPENAI_API_KEY and the workload-identity variables are
removed from what the CLI inherits, for the status check and the request alike. CODEX_HOME is
left alone: that is where your sign-in lives.
Codex reports no plan tier at all, so unlike the Claude lane there is nothing to verify beyond the sign-in method. A ChatGPT account without Codex quota passes the check and fails at the first message, where the CLI's own sentence reaches you.
Set codexCliPath in settings.json if codex is not on the PATH the game inherited.
This lane has never been run in game. It was written against OpenAI's published documentation — nothing on the development machine has the Codex CLI installed — so its flags are asserted against the documentation rather than against a measurement. Two consequences: a flag a future version drops shows up as a refusal that names the flag, and the safe-for-work tier's "0 explicit replies in 110 runs" was measured on the Claude lane, where the tier text is a real system prompt. Codex has none, so that number does not transfer to this lane.
By default the CLI is a coding agent: it loads CLAUDE.md from the working directory and from
your home, plus project settings, skills, plugins, hooks and MCP servers, and it carries a
system prompt about editing code. All of that would bleed into what a character says. The
plugin runs it with:
| Flag | Disables |
|---|---|
--safe-mode |
CLAUDE.md, skills, plugins, hooks, MCP, custom commands and agents |
--system-prompt-file |
the coding-agent system prompt, replaced by the character's |
--strict-mcp-config |
every MCP config on the machine |
--disable-slash-commands |
skills reachable via /name |
--tools "" |
every built-in tool — an allowlist naming none, so a tool added by a future CLI version cannot appear by default |
--disallowed-tools |
the same tools again, named one by one: a second line of defence if the flag above is ever ignored |
--no-session-persistence |
session files on disk |
empty temp cwd |
anything discoverable next to the working directory |
Do not swap --safe-mode for --bare. It disables more, but its own help states that
auth becomes strictly ANTHROPIC_API_KEY and that OAuth is never read — which defeats the
reason for using the CLI rather than the API.
The system prompt travels as a file rather than as an argument, which is what keeps this lane off the 32767-character ceiling Windows puts on a command line. Only the player's message goes on stdin. The two are never joined in REDscript: where the system block goes is the backend's decision, and it settles the instruction hierarchy the safe-for-work tier depends on.
Codex is the same problem with a smaller toolbox:
| Flag | Disables |
|---|---|
--disable shell_tool |
the shell — the tool leaves the session, it is not merely confined |
-c features.shell_tool=false |
the same switch again, in config form: a second line of defence |
-c features.multi_agent=false |
subagent tools |
-c features.hooks=false |
lifecycle hooks |
--ignore-user-config |
config.toml, and with it every MCP server, plugin, hook and profile |
--ignore-rules |
user and project execpolicy .rules |
-c project_doc_max_bytes=0 |
AGENTS.md, from any directory |
-c tools.web_search=false |
web search |
-c tools.view_image=false |
the local-image tool |
-c history.persistence="none", --ephemeral |
transcripts and session files on disk |
-c approval_policy="never" |
stopping to ask, which in a non-interactive run is a hang |
--sandbox read-only |
writing anything, and reaching the network |
empty temp cwd, --skip-git-repo-check |
anything discoverable next to the working directory (Codex refuses to run outside a git repository, hence the second flag) |
Why the shell is removed rather than sandboxed. Codex's own documentation says its
read-only sandbox permits reading files anywhere on the filesystem. A confined shell can
therefore still read your documents and put what it found in a bubble — which the mod writes to
its journal. --disable shell_tool is the Codex equivalent of Claude's empty --tools
allowlist: the tool is not in the session at all. --sandbox read-only stays behind it as the
second line of defence, in the same position as the Claude lane's denylist.
One difference remains. Codex has no system prompt, so the plugin folds the character's instructions into the single input it accepts, above the conversation and never below it. On this lane the instruction hierarchy is a convention in the text, not a privileged channel.
The prompt reaches it on stdin here too (codex exec -), for the same ceiling reason.
Two rules govern it, and each cost something to learn:
- Never select a process by image name. The machine may run other
claudeandnodeprocesses that belong to you or to something else. Only the PID the plugin spawned is touched. - Never leave an orphan. If the game crashes,
Unloadnever runs, so every child is put in a Job Object withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE— Windows kills it when the game process dies, however it dies.
It is now a hard dependency of the CLI lanes, and the failure mode is blunt. The mod's
scripts declare one native class (AiNpcCli), and RED4ext refuses to start the game at all
when a declared native class was registered by nobody — not the mod, the game. So a DLL that
is missing or rejected on a patch day stops the game booting. That risk is accepted for this
version and recorded, with the design that removes it, in
docs/ARCHITECTURE.md -- "What the plugin costs on a patch day".
Build it with plugin\build.ps1 (needs Visual Studio with the C++ toolset; no CMake — it is
a handful of translation units against a header-only SDK, compiled by cl.exe). Fetch the SDK
once with:
git clone --depth 1 https://github.com/WopsS/RED4ext.SDK.git vendor\RED4ext.SDK
Its own offline suite is plugin\test\run.ps1, and tools\test.ps1 runs it too.
A character writes text and nothing else. It cannot read a file, run a command, or reach the network, because those tools are not in the session at all — not forbidden, absent.
Cost. The CLI still ships a harness prefix on every call, but a much smaller one than it used to. Measured 2026-08-24 on the same prompt and model, prompt tokens including cache:
| tool restriction | prompt tokens |
|---|---|
--disallowed-tools alone (what this mod shipped before) |
15691 |
--tools "" |
3600 |
The tool schemas were still being sent under the denylist. That prefix is cached for an hour, so the first message of a session pays the cache write and the rest are cheap — but it is billed against the same subscription quota as your coding work.
Open the phone (contacts view), then:
| Key | Action |
|---|---|
T |
open the AI chat for the hovered contact |
Left click |
start typing |
Enter |
send |
Mouse wheel |
scroll history |
Z |
undo last exchange — Debug Mode only; without it the key does nothing and the hint is not drawn |
R |
reset the conversation |
C |
close and go back to the contact list |
Panam, Judy, River, Kerry, Songbird, Rogue, Viktor, Takemura, Jackie (pre- and post-heist),
and Mr. Studd (stud).
jackie_dead is the number after the heist, and nobody answers it: it returns one recorded
line — This number is no longer in service. — to every message, in the language the game
is running in. No prompt is built and no model is asked.
These are the built-ins, compiled in. Each is one file in src\r6\scripts\ai_npc\cast\
and one line in AiNpcBuiltinCast, plus its id in AiNpcGetAllContactIds. lint.ps1 fails
if those lists drift apart, and the store creates the conversation entry on first use.
You do not have to recompile to change or add a character. See below.
Every prompt fragment can be overridden from <game>\r6\storages\AiNpc\ without touching
the scripts, and contacts that belong to other mods can be given a voice there too:
| File | Holds |
|---|---|
prompts.json |
world background, mechanics, guidelines, tone tiers, per-language text |
characters.user.json |
your own character sheets and overrides — never overwritten |
characters.<mod>.json |
dropped in by another mod |
characters.example.json |
rewritten every launch, never read: the live schema reference |
facts.<mod>.json |
quest facts to watch, so a character reacts to another mod's content |
facts.example.json |
rewritten every launch, never read: the live schema for the above |
config-report.json |
written every launch: what loaded, what was rejected and why |
An absent or empty key means no opinion — the shipped text is kept. A file is read
onto a copy of the character's sheet, key by key, so it overrides a character and never
replaces them: a file that fails to load costs its own overrides and nothing else. The cast
itself is compiled in (src/r6/scripts/ai_npc/cast/, one file per character), so it cannot
fail to load at all.
Because the compiler no longer checks this text, everything is validated at start instead:
unparseable files, unknown keys, unknown variant conditions, and action tags no parser
handles are all reported to config-report.json and to the RED4ext log through
FTLogError — visible whether or not the in-game logging toggle is on. Read that report
first when a character does not behave the way its file says.
docs/API.md is the integration guide. In short: another mod gives one of its own contacts
an AI voice either by dropping a characters.<mod>.json file — which creates no
compile-time dependency on ai_npc, so it is simply ignored when ai_npc is absent — or, when
the contact needs live game state, by subclassing AiNpcContactProvider and calling
AiNpcRegisterContact.
Registration is dynamic: nothing needs to exist at launch, a provider can be registered and
unregistered mid-session, and the phone picks the change up the next time its contact list
is built. AiNpcContactHash derives the stable Int32 that
Phone Extension Framework wants for a
custom contact.
src/r6/scripts/ai_npc/
AiNpcHistory.reds pure history model: append, trim, undo, transcript, legacy import
AiNpcMemory.reds pure memory model: what the history becomes when it leaves the window
AiNpcMemoryService.reds the thinking lane: compacts a batch into a memory, invisibly
AiNpcLlm.reds the transport both lanes share: url, model, headers, chat body, timeout
AiNpcWatchdog.reds the clock both lanes arm: a request that never comes back still ends
AiNpcJournal.reds pure journal model: operations, replay, branch naming
AiNpcConversationStore.reds journal files, session lifecycle, the savegame pointer, migrations
AiNpcResponses.reds defensive readers for LLM API responses
tests/ runtime assertions over the pure modules, one file per subject
(count reported by test.ps1; the whole folder is dropped from a
release build, see Packaging). AiNpcTestSuite.reds in there is
nothing but `module AiNpc.TestSuite`: the marker AiNpcSelfTest.reds
asks about, so the assertions live in `module AiNpc` and stay
unexported. It leaves with the folder, never alone
AiNpcStorage.reds settings.json, created with defaults on first run, written back on edit
AiNpcSetup.reds the configuration surface CET talks to: describe, change, test, apply preset
AiNpcInstallPreset.reds what the FOMOD installer chose; one variant per provider in the archive
AiNpcConfig.reds loads + validates prompts.json / characters.*.json, writes the report
api/ THE public surface: everything another mod may call. Nothing outside
this folder is part of the deal -- see docs/API.md. AiNpcApi (the
free functions), AiNpcClient (the handle and the two doors into it),
AiNpcContactProvider / AiNpcExtension / AiNpcObserver (what a mod
subclasses), AiNpcMessage, AiNpcPromptOverrides, AiNpcContactTags,
AiNpcActionHandler, AiNpcScriptedReply, AiNpcJournalApi
AiNpcContactRegistry.reds which provider answers for a contact id, and the contact hashes
AiNpcFactBridge.reds the quest-fact bridge: another mod's fact in, an acknowledgement out
AiNpcRomanceExtension.reds ai_npc's own romance rubric, contributed through ai_npc's own
extension API: <relationship> is the psychology either way, this
adds what the romance changes -- or states the refusal when it
has not happened
AiNpcUtilities.reds reaching the mod's own systems, and the settings asked for by name
AiNpcChatDoor.reds the one place a conversation opens and closes, for BOTH surfaces:
the session's shown contact, the two listener events, and the
nudge to the memory service. A surface announcing for itself is
what left AGENT LINK silent
AiNpcSystem.reds ScriptableService: the phone chat's MODEL -- open/closed, typing,
key bindings, and the two vanilla phone controllers it holds. The
open thread is the session's, not a field here
AiNpcModSettings.reds the ten fields the Mod Settings menu binds to, read only through the
named accessors in AiNpcUtilities.reds
AiNpcEconomy.reds the eddie grant; AiNpcTransferLedger.reds is what each contact has
already released, and the bound on it
AiNpcPendingContextService.reds what mods have told a character and it has not heard yet
AiNpcPhoneState.reds where the player is in the phone and HOW they got there: the four
screens, the edges between them, and nothing else may decide one
AiNpcPhoneRenderer.reds the phone chat's PAINTER: every widget handle in the mod, and the
AiNpcChatView it registers while the chat is on screen
AiNpcPhoneWidgets.reds one validated walk of the vanilla contact list, or nothing --
plus the "T" badge grafted onto a row, which is the same walk
AiNpcHttp.reds the speaking lane: one request in flight, its failures, its repair
AiNpcContextData.reds quest-state -> prompt context lookups
AiNpcWeather.reds the sky, as one word for the prompt
AiNpcFactEvent.reds another mod's sentence, framed as the world reporting
AiNpcHooks.reds @wrapMethod hooks into the vanilla phone UI. Reports facts to
AiNpcSystem and decides nothing; tools\lint.ps1 enforces it
AiNpcTerminal*.reds AGENT LINK: the same conversations on a Night City computer, as a
BrowserExtension site. Site, chat, style, text, sort, input field
src/archive/pc/mod/
ai_npc.archive one file: the AGENT LINK icon. Built by tools\build-archive.ps1
from src\archive\, never edited by hand -- docs/branding/
docs/
ARCHITECTURE.md the map of the running system, and the invariants no compiler sees
API.md integration guide for other mods: JSON contacts + script providers
MEMORY.md design of the conversation memory: model, invariant, lanes, ordering
VIEW_ARCHITECTURE.md the rules the two chat surfaces obey, and what lint keeps
PROMPT_BUDGET.md what each prompt section costs, and the toggles that let the player spend it
TOKEN_BUDGET.md the daily cap: where "today" comes from, and what it refuses
MODEL_COSTS.md what an hour of play costs, per model
CHARACTER_RULES.md how an action criterion is written; CHARACTER_PROCESS.md is the procedure
ARC_FACTS.md the quest facts that say how a character's arc ended
DISTRIBUTION.md which lanes may be shipped, and to whom
CREDITS.md where the mod comes from, and what it owes
PLAN_*.md work briefed and not yet done
branding/ the AGENT LINK icon, the AI NPC Nexus image, and how a .png becomes
an icon on a terminal
tools/
lint.ps1 static checks (runs in under a second)
compile-check.ps1 isolated redscript type-check against the vanilla bundle
test.ps1 runs lint + compile-check + reports the last runtime results
deploy.ps1 type-checks, then copies src/ into the Vortex staging folder and the game
package.ps1 builds a distributable zip in dist/ (runs build-archive.ps1 first)
build-archive.ps1 packs src\archive\ into src\archive\pc\mod\ai_npc.archive
set-version.ps1 writes the version in the two places that state it; run with no
argument it prints them. lint.ps1 is what refuses a disagreement
make-icon.py draws the AGENT LINK icon and the Nexus image into docs/branding/
inkatlas/, archive/ the two C# helpers those two use (WolvenKit 8.20 DLLs, dotnet 8)
prompt/ rebuilds offline the exact prompt the mod sends, and compares it
byte for byte to a capture. Run by test.ps1 -- see Tests
What is not here. The bench that chose the shipped defaults lives in a separate,
private repository, ai_npc_lab, beside this one: provider runs and their replies, the
explicitness measurements, the vocabulary extraction, the conversation-journal readers, and
the captured play samples they read. Documents here cite it by name where a number came from
one of those runs. Nothing in this repository needs it -- the mod builds, packages and passes
its tests without it. The prompt regression suite stayed here on purpose, because it is a
check on the mod rather than an experiment.
Two names, on purpose. The site is called AGENT LINK in the game -- an Agent is the pocket assistant a citizen carries, and this is its companion on a desk. The mod is called ai_npc on Nexus and on disk. Nothing the player reads says "ai_npc": that name describes what the thing is made of, which no application in Night City would announce.
REDscript without a module declaration puts everything in one flat global namespace: two mods
that each declare class HttpRequestSystem will not compile together. Everything here is
therefore prefixed AiNpc* and lives in module AiNpc — anything not marked public is
invisible outside the mod, and ModuleExists("AiNpc") gives third-party mods a compile-time
way to depend on this one optionally.
The phone is the part no module boundary can protect. The hooks are @wrapMethod on vanilla
widgets and the key handling is raw, so both are shared surfaces by construction: run only
one mod that texts through the phone. Two of them compile fine and then answer the same
keypress.
History is one ordered list of messages per contact, each tagged with its author, and it is tied to your savegames: reload an earlier save and the chat rewinds with it; start a new game and the characters have never met you.
The text itself stays out of the savegame. Instead the save carries a pointer into an append-only log on disk — one file per branch, one JSON operation per line:
{"h":3,"b":"b3","from":"b1:100"}
{"n":101,"c":"judy","o":"a","p":true,"t":"hey","g":90000}
{"n":102,"c":"judy","o":"a","p":false,"t":"hey yourself","g":90180}
"g" is the in-game clock when the message was written, in absolute seconds — so a reload
knows not just the order of the messages but when each one was sent, and how long a
character was left waiting. It is absent on anything written before the field existed, and
on a seeded history; readers treat a missing "g" as "unknown", never as time zero.
What the character sees. Not a timestamp per line — that would spend context repeating
something that rarely changes, and models copy the format straight into their reply. The
transcript marks the breaks instead, on their own line, so the V: / Judy: grammar the
stop sequences rely on is never touched:
V: t'es où ?
Judy: bar, comme d'hab
(2 days later, 3:00pm)
V: désolé, j'ai disparu
(7 hours later, 10:00pm)
The scale picks the wording: under 30 minutes nothing at all (an hour of chat with a message
every ten minutes reads as one scene), then (2 hours later), then the clock joins in past
six hours, and past a day it counts days — 195 hours later is not something anyone reads.
The last marker is the one that matters most: the silence between the final stored message
and the line V is sending right now. A history with no stored times renders exactly as it did
before any of this existed.
Loading is a replay of every operation up to the saved sequence number. The savegame's share of this is two integers — the branch id and that sequence number — kept current on every message, so whatever moment you save at is the moment you come back to.
Branches. Save at 100, play on to 150, reload: those later numbers belong to a timeline this save is no longer on. The next message therefore forks — a new branch file opens with a snapshot of the replayed state, and the two timelines never share a number again. Branch ids come from a counter that is never decremented, so a deleted branch's id is never handed out twice: a save pointing into a branch that is gone loads an empty history, never someone else's.
Only the last 24 branches are kept. Nothing can enumerate your savegames from inside the game, so no branch can ever be proven dead — the guarantee is one-sided on purpose: a very old save may find its history gone, but no save ever gets the wrong one.
Memory. Past sixteen turns a conversation no longer sends everything it holds. The oldest batch is compacted, out of character and out of sight, into a small structured memory — established facts, still-open threads, one line of tone — which travels in the same journal entry that evicts the messages it absorbed, so a reload can never land between the two. What the character still has verbatim is the last six turns; what it has before that, it remembers the way a person does, in less and less detail. The design, the invariant it rests on, and why the summarising request is kept on a separate lane from the one the player is waiting for, are in docs/MEMORY.md.
Two settings, in Mod Settings → AI NPC → Memory: the feature itself (on by default) and Memory Size, how many consolidated facts a character keeps in front of it — 20 by default, 8 to 40. Raising it only costs tokens, about 30 per fact on every request. Lowering it drops the oldest facts out of the prompt at the next compaction: they stay in the archive on disk, but the character stops knowing them. The menu says exactly that, because the slider is the only place a player can see the trade.
AiNpcConversationStore owns all of this and is the single source of truth — nothing else
caches a copy. AiNpcHistory.reds (append, trim, undo, transcript) and AiNpcJournal.reds
(operations, replay) hold the model as pure functions: no game API, no disk, no globals.
The live path and the replay call the same AiNpcJournalApply, so "what the session did" and
"what a reload rebuilds" cannot drift apart. That purity is what makes them unit-testable,
and lint.ps1 enforces it.
Upgrading. An existing conversations.json (schema v2, or the older "a|b|"-joined
format) is converted once into a read-only seed branch, and backed up as
conversations.v2.json. Saves made before the upgrade adopt that seed, so nothing is lost;
each forks its own branch on its next message. A brand new game is never offered it — which
is what stops a fresh V from remembering the previous playthrough.
ai_npc_lab\journal\journal.py replays the same model in Python, so the branch files can be read — and,
when something goes wrong, rewritten — without launching the game.
python ai_npc_lab\journal\journal.py list # every branch, and the pointers seen in gamelog.log
python ai_npc_lab\journal\journal.py show b14:467 --only MiraContact --tail 10
python ai_npc_lab\journal\journal.py import --from b14:467 --into b14 --apply # fallback; prefer the setting belowlist is the diagnostic: one line per branch with its parent, its head, how many
conversations and messages it replays to, and — read from gamelog.log, the only place a
savegame's pointer is ever visible from outside the save — which pointer each recent session
actually restored. That is how a save is matched to a branch.
tools\utf8.py scans everything the mod has written to disk for malformed UTF-8, and drops
it on request.
python tools\utf8.py # report on the game's storage
python tools\utf8.py --repair # drop the bad bytes, keeping a backup (game closed)The symptom it answers is specific and misleading: one contact stops answering, on the phone and on the terminal alike, while the others are fine. A stored message holding a malformed byte makes every later request for that contact malformed too — the whole conversation is re-read into each one — and such a request never leaves the client. What comes back is status 0, which looks exactly like a network or a TLS problem and is neither.
The mod no longer produces such a byte (text a player types is measured in characters, not
bytes — AiNpcUtf8.reds), so this is for a history written before that fix.
The situation this answers: a save has been lost — corrupted by another mod, say — and play has to resume from an earlier one, which rewinds every conversation with it.
The pointer lives inside the savegame, so nothing outside the game can aim an old save at
a newer history. The mod therefore does it from inside. Put the pointer you want in
settings.json, load the save you are keeping, and the store adopts it:
"importConversationsFrom": "b14:467"settings.json is re-read on every save load, so no relaunch is needed: edit the file,
load the save from the main menu, and delete the key again afterwards — all in one sitting.
journal.py list is where that pointer comes from — it prints, from gamelog.log, which
pointer each recent session restored, so the lost save is identified by the conversation
counts it loaded with. "b14" with no number means "as far as that branch goes".
What happens on the next load is a replay of that pointer followed immediately by a fork:
the save comes out on a branch of its own, seeded with the imported conversations. Forking
rather than adopting the source pointer is the safety of it — two saves sharing a branch is
the bug this journal exists to prevent — and it means the source file is never written to, so
the import can be repeated, aimed elsewhere, or undone by loading a save that predates it.
journal.index.json records the provenance as "from": "b14:467".
Two persistent fields remember what a savegame has imported, so a key left in place cannot
re-import over live conversations on the next load. Every other save you open still will,
though — which is why the log says so twice, and why the key is meant to be deleted once it
has run. Confirm in gamelog.log:
[ai_npc]: imported 19 conversation(s) from b14:467 into b16:19.
From CET, when you do not yet know which pointer you want — which is the normal case,
because the pointer is the thing you are looking for. The mod ships a CET window
(bin\x64\plugins\cyber_engine_tweaks\mods\ai_npc_debug\, installed by the archive like
everything else; ignored where CET is absent). Open the overlay, tab Journal — the
other tab is the setup window — and it asks the three questions in
order:
- which branch was the lost playthrough? — the table lists every branch with its parent and head; selecting one replays it and shows each contact, its message count, and the last thing said in it. You recognise a playthrough by reading it, not by counting it.
- how far back? — the sequence field is prefilled with the branch head; Preview re-reads the branch as it stood at any earlier number.
- restore it onto the game being played — a confirmation checkbox names the pointer that is about to replace this savegame's history, and after the import an Undo button offers the pointer you were on before, because nothing else remembers it.
Nothing is overwritten on disk at any point: restoring replays the source and forks. The same
functions are on the console — GetMod("ai_npc_debug").status(), .branches(),
.detail("b14:96"), .restore("b14:467") — and on the store itself for anyone who would
rather not install the CET mod:
local s = Game.GetScriptableSystemsContainer():Get("AiNpc.AiNpcConversationStore")
print(s:DescribeBranches())
print(s:DescribeBranchDetail("b14:467"))
print(s:ImportFromPointer("b14:467"))Bare AiNpcJournalStatus() will not work: CET has no such Lua global. The functions in
AiNpcJournalApi.reds are for redscript callers — another mod taking ai_npc as an optional
dependency — and are documented with the store's own methods in
docs/API.md.
Same work as the settings key, same fork, same log line — it simply happens now instead of at the next load. Close the phone first; the import refuses while a conversation is on screen.
The offline alternative. journal.py import --from b14:467 --into b14 --apply rewrites
the branch file itself so that every save pointing at it replays the imported state. It needs
no game launch and can cherry-pick contacts (--only MiraContact judy), but it replaces a
branch file — the old one is kept as journal.b<N>.jsonl.bak — and it affects every save on
that branch rather than the one you chose. Prefer the setting; keep this for when the game
cannot be launched, or when only part of a history should come across. Quit the game to
desktop first either way: the store appends to these files live.
powershell -File tools\test.ps1Three stages. The first two need no game launch:
-
lint.ps1— static checks on the sources: no credentials committed, no duplicate top-level declarations, every[ACTION:...]tag advertised in a prompt is one the parser actually matches, every member of an enum the settings menu exposes has a display value, andAiNpcGetAllContactIdsagrees withAiNpcGetContactId. -
compile-check.ps1— runs the real redscript compiler (engine\tools\scc.exe) against the vanilla script bundle plus only the declared dependencies, in a temp directory. It never touches the game's script cache. Because the dependency set is explicit, this also proves the mod does not silently rely on some unrelated mod you happen to have installed. -
runtime self-tests — assertions over the pure modules, in
src\r6\scripts\ai_npc\tests\. REDscript has no standalone runtime, so they execute inside the game (at storage init, cost is a few hundred string operations) and writer6\storages\AiNpc\test-results.json.test.ps1reads that file back and reports it, including how stale it is. Failures are also logged as errors.
What none of this covers: widget hierarchy traversal, ScriptableSystem lifecycle, and real
HTTP responses. Those still need manual play-testing.
powershell -File tools\deploy.ps1Type-checks, then copies src\r6\scripts\ai_npc\*.reds into both the Vortex staging
folder and the game — and deletes any .reds there that no longer exists in src.
Both targets matter. This mod is installed through Vortex, which keeps its own staging copy and hardlinks it into the game folder: writing only to the game works until Vortex next deploys or purges, then silently reverts; writing only to staging does nothing until Vortex deploys. A stale script left behind is worse than either: a renamed file that is still present declares the same symbols twice in the same module, which is a compile error that does not reproduce from source.
Adjust -GameDir / -StagingDir if your paths differ; -SkipChecks skips the type-check.
Then launch the game: it compiles the scripts and runs the self-tests, and
tools\test.ps1 reports the result afterwards.
powershell -File tools\package.ps1 # debug: the self-tests come with it
powershell -File tools\package.ps1 -Release # release: what goes on NexusTwo builds, and the difference is one folder. A -Release build drops
src\r6\scripts\ai_npc\tests\ -- roughly 5000 lines of assertions the player never reads,
compiled at every game start. The folder is the unit rather than a list of file names, so a
test file added tomorrow cannot reach a player because nobody extended the list. It carries its
own marker: AiNpcTestSuite.reds, the empty file whose only job is to declare the module
AiNpcSelfTest.reds asks about, sits inside it and leaves with it. tools\package.ps1 checks
what the folder cannot say by itself -- that the folder is there, and that the marker is in it,
because a marker left outside would survive the release build and report "0 tests".
A debug build keeps both, so the suite runs at startup and writes r6\storages\AiNpc\test-results.json,
which is what tools\test.ps1 reports on.
Release is the default on purpose: the build that leaves this machine is the one that must
not need a flag to be correct. Nothing else differs between the two -- the seam is
AiNpcSelfTest.reds, fifteen lines of @if(ModuleExists("AiNpc.Tests")) whose whole job is
to make deleting the other file legal. Both shapes are type-checked by tools\test.ps1;
compile-check.ps1 -WithoutTests checks the release one on its own.
Produces two archives in dist\:
| File | Use |
|---|---|
ai_npc-<version>.zip |
archiving, and uploading to Nexus |
ai_npc-<version>-debug.zip |
the same build with the self-tests, never uploaded |
ai_npc.zip |
installing into Vortex -- whichever configuration was built last |
The stable name is not cosmetic. Vortex derives a manually installed mod's identity from
the archive file name — verified in its own state database, where this mod is recorded
with archiveId: null and no version, modId, fileId or source attribute at all,
only fileMD5, fileName and name. So ai_npc-0.6.2.zip and ai_npc-0.7.0.zip install
as two unrelated mods, and Vortex never offers to replace one with the other: the
"already installed" prompt keys off that derived identity, and two different names cannot
collide. Installing ai_npc.zip every time makes successive builds collide on purpose,
which is what produces the Replace prompt.
Nothing is lost by dropping the version from that file name: it is compiled into
AiNpcVersion() and logged at startup, which is the only place it can be checked once the
mod is installed.
-NoStableCopy skips the second archive.
The version comes from AiNpcVersion() in AiNpcUtilities.reds, which is the source of
truth. Bump it there; the archive is named from it, and it is logged unconditionally at
startup so an installed build can be identified from the log. Passing -Version is allowed
only to assert what the sources already say — a mismatch fails the build rather than
producing an archive whose name contradicts its contents.
That matters because nothing reads a version out of the archive after installation.
Vortex identifies a mod by the MD5 of its archive against its meta server, or by Nexus
download metadata; for a locally built zip it has neither. There is no in-archive manifest
format that would change this, and Cyberpunk's REDmod info.json does not apply — it only
covers mods deployed under mods\, while this one installs to r6\scripts and
red4ext\plugins. Hence the stable archive name above; publishing on Nexus (real
modId/fileId) is the other way out.
-SkipPlugin builds a scripts-only archive for the cloud providers.
Note: redscript sources cannot be shipped pre-compiled — the redscript compiler rebuilds
final.redscripts at every game launch from all installed mods, and that bundle is specific
to your game build. "Compiled mod" here means packaged for one-click install.
The mod is MIT; the repository is not. The grant covers what ships to a player — src/,
plugin/ and fomod/. The tooling in tools/, the documentation in docs/ and the raw
asset sources in source/ are not licensed, and vendor/ is somebody else's submodule under
its own terms. LICENSE states the scope, and docs/CREDITS.md is the full attribution.
This is not a fork. ai_npc was written from scratch. It is not a fork of Immersive Companion Generative Texting, of Generative Texting, or of anything else in that family, and it ships no line of their code and none of their prompts. It is not one more variant — it is a different mod that came from the same idea.
And it would not exist without them. Their mods were played long before a line of this one
was written. That is where the idea came from: a character answering on the player's own
in-game phone, quest state feeding what that character says, and jackie_dead — a contact who
answers from the far side of their own death. Thank you to their authors for clearing the road.
Some things are kept on purpose, so a player arriving from those mods finds what they expect:
the T key, and the contact identifiers — jackie, jackie_dead, panam,
kerry_eurodyne, judy, songbird, river_ward, rogue, victor_vector, takemura.
What the licence does not cover. MIT covers the mod's own code. It does not cover the frameworks — each installed by the player under its own terms — nor the game's characters and setting, which are CD Projekt Red's.
ai_npc is an unofficial fan modification, not affiliated with or endorsed by CD Projekt Red.