Python SDK

Install the foilengine package and integrate NPC conversations into your Python project.

Installation

pip install foilengine

Quick Start

from foilengine import FoilEngineClient

client = FoilEngineClient(
    api_key="pk_live_...",
    llm_api_key="sk-...",   # your LLM provider API key
    llm_model="gpt-4o",     # any LiteLLM-supported model
)

# Discover your personas
personas = client.personas.list()
print(personas[0].name)  # "Grumpy Barista"

# Initialize a session
session = client.chat.init_session(
    persona_id=personas[0].id,
    user_session_id="player-001",
    player_name="Alex",
    player_gender="non-binary",
)
print(session.message)  # NPC's greeting

# Send a message
response = client.chat.send_message(
    persona_id=personas[0].id,
    message="What do you recommend?",
    user_session_id="player-001",
)
print(response.message)       # NPC's reply
print(response.current_state) # State machine state
print(response.score)         # Session score

Configuration

ParameterTypeDefaultDescription
api_keystrrequiredYour API key
base_urlstrhttps://api.foilengine.ioAPI base URL (override for local dev)
timeoutfloat30.0Request timeout in seconds
max_retriesint3Max retries on 429/5xx errors
llm_api_keystrrequired for chatYour LLM provider API key (OpenAI, Anthropic, etc.)
llm_modelstr | NoneNoneDefault LLM model for all pipeline steps
llm_eval_modelstr | NoneNoneOverride model for message evaluation
llm_response_modelstr | NoneNoneOverride model for NPC response generation
llm_summarization_modelstr | NoneNoneOverride model for conversation summarization
llm_eval_api_keystr | NoneNoneOverride API key for evaluation (use a different provider)
llm_response_api_keystr | NoneNoneOverride API key for NPC response generation
llm_summarization_api_keystr | NoneNoneOverride API key for conversation summarization
debugboolFalseEnable debug logging (logs requests, responses, and timing to the foilengine logger)
cache_ttlfloat60Cache TTL in seconds for personas.list() (set to 0 to disable)
hookslist[RequestHook] | NoneNoneRequest lifecycle hooks for logging, analytics, or telemetry

Available Methods

Personas

MethodReturnsDescription
client.personas.list()list[Persona]List all published personas

Utilities

MethodReturnsDescription
client.validate_llm_key(model=None)dictValidate your LLM API key (returns {"valid": bool, "model": str})

Machines

MethodReturnsDescription
client.machines.list(persona_id, user_session_id)list[MachineInfo]List available machines for a player

Chat

MethodReturnsDescription
client.chat.init_session(...)SessionInitStart a new conversation
client.chat.send_message(...)ChatResponseSend a message, get NPC reply
client.chat.send_message_stream(...)Iterator[ChatStreamEvent]Stream NPC response via SSE (metadata first, then text chunks, then done)
client.chat.get_session(...)SessionStatusCheck if a session exists
client.chat.get_history(...)ChatHistoryGet full message history
client.chat.reset(...)ResetResultDelete a session

Async Support

Use AsyncFoilEngineClient for async/await:

from foilengine import AsyncFoilEngineClient

async def main():
    async with AsyncFoilEngineClient(
            api_key="pk_live_...",
            llm_api_key="sk-...",
            llm_model="gpt-4o",
        ) as client:
        personas = await client.personas.list_async()
        response = await client.chat.send_message_async(
            persona_id=personas[0].id,
            message="Hello!",
            user_session_id="player-001",
        )
        print(response.message)

Error Handling

All API errors are raised as typed exceptions:

from foilengine import (
    FoilEngineClient,
    AuthenticationError,
    NotFoundError,
    RateLimitError,
)

client = FoilEngineClient(
    api_key="pk_live_...",
    llm_api_key="sk-...",
)

try:
    session = client.chat.get_session("persona-id", "player-001")
except NotFoundError:
    # No existing session — initialize one
    session = client.chat.init_session(...)
except AuthenticationError:
    print("Invalid API key")
except RateLimitError as e:
    print(f"Rate limited. Retry after {e.retry_after}s")
ExceptionStatusWhen
BadRequestError400Invalid UUID, missing fields
AuthenticationError401Missing or invalid API key
ForbiddenError403Not owner or persona unpublished
NotFoundError404Resource not found
RateLimitError429Rate limit exceeded
ServerError500Internal server error
💡Tip
The SDK automatically retries on 429 and 5xx errors with jittered exponential backoff (up to 3 retries). You only need to handle errors that persist after retries. Enable debug=True to see request/response logs.

Streaming Responses

Stream NPC responses token-by-token for a typing effect. Metadata (state, score, decision) arrives before any text, so you can update your game UI immediately.

for event in client.chat.send_message_stream(
    persona_id=persona_id,
    message="Tell me about your potions.",
    user_session_id="player-001",
):
    if event.event_type == "metadata":
        # All game state available before text starts
        meta = event.data
        print(f"State: {meta.current_state}, Score: {meta.score}")
    elif event.event_type == "text_delta":
        # Progressive text chunks
        print(event.data["text"], end="", flush=True)
    elif event.event_type == "done":
        # Full ChatResponse for final reconciliation
        print(f"\nFinal: {event.data.message}")

Events

Register callbacks to react to game-relevant events like state transitions, score changes, or machine completion. Events fire automatically after each send_message() call.

client.on("state_change", lambda e: print(f"State: {e['from_state']} -> {e['to_state']}"))
client.on("score_change", lambda e: print(f"Score: {e['old_score']} -> {e['new_score']}"))
client.on("machine_completed", lambda e: print(f"Done! Outcome: {e['outcome']}"))
client.on("machines_unlocked", lambda e: print(f"Unlocked: {e['machines']}"))
client.on("session_ended", lambda e: print(f"Session ended: {e['outcome']}"))
EventDataWhen
state_changefrom_state, to_state, decisionState machine transitions to a new state
score_changeold_score, new_score, deltaSession score changes
machine_completedoutcome, final_score, session_idConversation reaches a terminal state
machines_unlockedmachines (list of machine_key, name)New machines become available after completion
session_endedoutcome, final_score, session_idSession receives an outcome (ACCEPT, REJECT, KICK_OUT)

Request Hooks

Add custom logging, analytics, or telemetry with request lifecycle hooks.

from foilengine import FoilEngineClient, RequestHook

class LoggingHook(RequestHook):
    def before_request(self, method, url, headers, body):
        print(f"-> {method} {url}")

    def after_response(self, method, url, status, elapsed_ms, body):
        print(f"<- {status} in {elapsed_ms:.0f}ms")

    def on_error(self, method, url, error):
        print(f"!! {error}")

client = FoilEngineClient(
    api_key="pk_live_...",
    llm_api_key="sk-...",
    hooks=[LoggingHook()],
)

Multi-Machine Example

# Discover available machines for a player
machines = client.machines.list(persona_id, "player-001")

for m in machines:
    status = "active" if m.has_session else "available"
    linked = " (linked)" if m.is_linked else ""
    print(f"  {m.name}: {status}{linked}")

# Start a specific machine
session = client.chat.init_session(
    persona_id=persona_id,
    user_session_id="player-001",
    player_name="Alex",
    player_gender="non-binary",
    machine_id=machines[1].machine_id,  # target a specific scenario
)

Complete Example

A full, runnable script that initializes a session, sends messages, handles streaming, and listens for events:

from foilengine import FoilEngineClient

client = FoilEngineClient(
    api_key="pk_live_...",
    llm_api_key="sk-...",
    llm_model="gpt-4o",
)

# Listen for game-relevant events
client.on("state_change", lambda e: print(f"  [state] {e['from_state']} -> {e['to_state']}"))
client.on("score_change", lambda e: print(f"  [score] {e['old_score']} -> {e['new_score']}"))
client.on("machine_completed", lambda e: print(f"  [done] Outcome: {e['outcome']}, Score: {e['final_score']}"))

# Discover personas
personas = client.personas.list()
persona = personas[0]
print(f"Chatting with: {persona.name}")

# Start a session
session = client.chat.init_session(
    persona_id=persona.id,
    user_session_id="player-001",
    player_name="Alex",
    player_gender="non-binary",
)
print(f"NPC: {session.message}")

# Send a message (non-streaming)
response = client.chat.send_message(
    persona_id=persona.id,
    message="What do you have for sale?",
    user_session_id="player-001",
)
print(f"NPC: {response.message}")
print(f"  State: {response.current_state}, Score: {response.score}")

# Send a message (streaming)
print("NPC: ", end="")
for event in client.chat.send_message_stream(
    persona_id=persona.id,
    message="Tell me more about that.",
    user_session_id="player-001",
):
    if event.event_type == "metadata":
        pass  # State/score available here before text starts
    elif event.event_type == "text_delta":
        print(event.data["text"], end="", flush=True)
    elif event.event_type == "done":
        print()  # Newline after streaming completes

# Check session status
status = client.chat.get_session(persona.id, "player-001")
print(f"Session state: {status.current_state}, score: {status.score}")

# Reset when done
client.chat.reset(persona.id, "player-001")
print("Session reset.")