Model Interaction Capture
Use model interaction capture when a cloud model is giving your application better answers than the small model you ultimately want to ship. It saves the prompts your application sent to the teacher model and the teacher's completed answers as JSONL training examples. You can then review, filter, and fine-tune a smaller local model on the behavior that matters to your game or application.
For example, an NPC dialogue system can initially route requests to a proxy backend such as Claude, Gemini, or OpenAI. Once it has a useful collection of quest dialogue, you can fine-tune a small local model and replace the proxy without changing the dialogue call sites.
Capture is off by default. Enable it once when you create the engine; every normal request continues to return exactly as it did before. The application does not wait for a file write: Atelico queues completed records and writes them on a separate background thread.
When to use it
Model interaction capture is useful when you want to:
- collect real prompts and high-quality teacher answers for a fine-tuning set;
- compare a new cloud model or prompt with the model you plan to ship;
- retain a small sample of production behavior for evaluation and regression tests;
- collect data first, then move an application from a cloud proxy to a local model.
It is not a replacement for application analytics or error reporting. Capture stores model inputs and outputs, which may include player text, system prompts, and other sensitive application data. Enable it only where you are allowed to retain that data, choose a directory with appropriate access controls, and delete or filter records that should not become training data.
Quick start
Add a data_logging object to the engine configuration before starting the
engine. This example keeps every completed chat request sent to the teacher
proxy backend in teacher-data/:
{
"backends": [
{
"name": "teacher",
"type": "proxy",
"base_url": "https://api.openai.com/v1"
}
],
"data_logging": {
"enabled": true,
"output_dir": "teacher-data",
"backends": ["teacher"],
"request_types": ["chat_completion"]
}
}
Use teacher::your-model-id in the normal chat request. No logging call is
needed around that request. Atelico creates files such as
teacher-data/2026-07-12.jsonl; each line is one completed interaction.
The example omits the provider credential. Supply it through the proxy backend configuration exactly as you normally do; the Rust, Python, and Godot examples below show environment-backed credentials.
Start with sample_rate: 0.1 for a production trial. Increase it after you
have checked the data volume and quality. Use backends to make sure you only
collect answers from the teacher backend, not requests served by a local model.
Configure it from your SDK or plugin
All SDKs use the same data_logging shape. Configure it before creating the
engine (or, for Godot, before initialize_engine). Your normal model calls are
unchanged.
Rust SDK
use atelico_sdk::{
BackendConfig, BackendType, DataLoggingConfig, Engine, EngineConfig,
};
let engine = Engine::from_config(EngineConfig {
backends: vec![BackendConfig {
name: "teacher".into(),
backend_type: BackendType::Proxy,
base_url: Some("https://api.openai.com/v1".into()),
api_key: Some(std::env::var("OPENAI_API_KEY")?),
..Default::default()
}],
data_logging: Some(DataLoggingConfig {
enabled: true,
output_dir: "teacher-data".into(),
backends: vec!["teacher".into()],
request_types: vec!["chat_completion".into()],
..Default::default()
}),
..Default::default()
})?;
// Existing calls need no wrapper. A request using `teacher::...` is captured.
# let _ = engine;
# Ok::<(), anyhow::Error>(())
- Python
- Godot (GDScript)
- Unity (C#)
- Unreal Engine
- Lua
Pass a full engine configuration to Engine. The normal chat method is the
same one you use without capture.
import json
import os
from atelico import Engine
engine = Engine(config_json=json.dumps({
"backends": [{
"name": "teacher",
"type": "proxy",
"base_url": "https://api.openai.com/v1",
"api_key": os.environ["OPENAI_API_KEY"],
}],
"data_logging": {
"enabled": True,
"output_dir": "teacher-data",
"backends": ["teacher"],
"request_types": ["chat_completion"],
},
}))
reply = json.loads(engine.llm_chat_completion(json.dumps({
"model": "teacher::gpt-4.1-mini",
"messages": [{"role": "user", "content": "Give my NPC a greeting."}],
})))
print(reply["choices"][0]["message"]["content"])
If your application already uses the default local engine, the short form is
Engine(data_logging_json=json.dumps({"enabled": True, ...})).
Set capture before initializing the node. Use a real writable OS path for the
output directory; OS.get_user_data_dir() is a good per-user location.
@onready var engine: AtelicoEngineNode = $AtelicoEngine
func _ready() -> void:
var log_dir := OS.get_user_data_dir().path_join("teacher-data")
assert(engine.set_data_logging(JSON.stringify({
"enabled": true,
"output_dir": log_dir,
"backends": ["teacher"],
"request_types": ["chat_completion"]
})))
engine.initialize_engine([{
"name": "teacher",
"type": "proxy",
"config": {
"base_url": "https://api.openai.com/v1",
"api_key": OS.get_environment("OPENAI_API_KEY")
}
}])
Continue to call llm_chat, llm_chat_stream, or the other normal node
methods. A streamed response is written after the stream completes.
Select the AtelicoEngine component and paste this into its Config Json
field before entering Play mode:
{
"backends": [{"name": "in-memory", "type": "in-memory"}],
"data_logging": {
"enabled": true,
"output_dir": "teacher-data",
"sample_rate": 0.1,
"request_types": ["chat_completion"]
}
}
AtelicoEngine forwards configJson at startup. Your existing
engine.Llm calls then produce capture records automatically.
Open Project Settings → Atelico AI → Data Logging, then enable Data
Logging. Set a directory, sample rate, and (if needed) Redact Data Log
System Prompts. The plugin sends those settings when its engine subsystem is
created; your existing UAtelicoAISubsystem chat calls need no changes.
For a first run, use a dedicated directory such as
Saved/Atelico/teacher-data and a sample rate of 0.1.
Pass the same object through Engine.from_config:
local engine = atelico.Engine.from_config({
backends = {
{ name = "in-memory", type = "in-memory" },
},
data_logging = {
enabled = true,
output_dir = "teacher-data",
request_types = { "chat_completion" },
},
})
C FFI and custom hosts
Pass the full JSON document to atelico_engine_create(config_json, ...). This
is also the configuration path used by Unity. No per-request FFI calls are
needed, and capture never blocks the caller thread:
const char* config = "{\"data_logging\":{\"enabled\":true,\"output_dir\":\"teacher-data\",\"backends\":[\"openai\"]}}";
AtelicoEngine* engine = NULL;
int32_t result = atelico_engine_create(config, &engine);
See the C FFI API reference for the surrounding entry points.
Choose what to capture
The following fields go inside data_logging. Omit a field to use its default.
| Field | Default | Use it for |
|---|---|---|
enabled | false | Turning capture on. |
output_dir | data_logs | Choosing the JSONL destination. |
sample_rate | 1.0 | Keeping only a fraction of eligible calls. |
backends | [] (all) | Limiting collection to a teacher/proxy backend. |
request_types | [] (all) | Limiting collection to chat_completion, text_completion, response, image_generation, embedding, or background_removal. |
redact_system_prompts | false | Removing system messages before they reach disk. |
max_output_length | unset | Limiting captured text output to a byte count. |
log_errors | true | Retaining failed calls and their error text. |
buffer_size | 1024 | Setting the maximum in-memory record queue. |
batch_size | 64 | Choosing how many records the writer writes together. |
flush_interval_ms | 100 | Setting the longest delay before a partial batch is written. |
An empty backends or request_types list means "all". Backend filtering
matches the backend name, such as teacher in teacher::gpt-4.1-mini.
What you get on disk
The built-in sink appends one JSON object per line and rotates daily:
{
"schema_version": 1,
"timestamp": 1783872000,
"request_type": "chat_completion",
"backend": "teacher",
"model": "gpt-4.1-mini",
"input": {
"type": "chat",
"messages": [{"role": "user", "content": "Give my NPC a greeting."}]
},
"output": {"type": "chat", "content": "Welcome, traveler!"},
"metadata": {"latency_ms": 184, "streamed": false}
}
Keep the raw JSONL files immutable. A training-preparation job should select successful rows for the intended teacher model, remove entries that are not appropriate for training, and write the transformed chat dataset elsewhere. Do not train blindly on every captured response: review quality, consent, licensing, errors, and tool-only responses first.
Server deployments
If you operate atelico-server instead of an in-process SDK, enable the same
feature with environment variables:
ATELICO_DATA_LOG=1 \
ATELICO_DATA_LOG_DIR=/var/lib/my-app/teacher-data \
ATELICO_DATA_LOG_SAMPLE_RATE=0.1 \
./atelico-server
Set ATELICO_DATA_LOG_REDACT_SYSTEM=1 to omit system messages. See
Server Configuration for the complete server
environment reference.
Troubleshooting
- No JSONL file appears: make one routed model request, verify the process
can create
output_dir, and check that the selected backend/request type matches your filters. - A streamed answer is not there yet: the record is written after the stream has finished, not once per token.
- Too much data: reduce
sample_rate, restrictbackendsandrequest_types, or setmax_output_length. - Capture affects a busy game loop: it deliberately drops records when its
bounded queue is full instead of blocking inference. Increase
buffer_sizeonly if retaining more records is worth the extra memory.
API reference
- Core applications can use
atelico_core::data_log::{DataLogConfig, DataLogFormat, DataLogger, DataLogSink, DataLogStats}directly. - Routers expose
enable_data_logging,enable_data_logging_with_sink,disable_data_logging, anddata_log_stats. - SDK-backed applications use
atelico_sdk::DataLoggingConfigthroughEngineConfig.data_logging.