Quickstart
In this guide you run Taisce on your machine, save one thing a user said, ask about it, and then
forget it, in seven steps with curl. Then, in step 8, you give an agent memory in the framework you
use: Microsoft Agent Framework in Python or .NET, LangGraph, Spring AI, LangChain4j, or a coding agent
over MCP. Each step shows the command, what you should see, and what just happened. You don't need an
account anywhere, because the model runs on your own machine.
What you need
- Docker with the Compose plugin, so
docker compose versionanswers. The older standalonedocker-composewill not do. - Ollama, reachable from inside a container, because that is where Taisce calls the model from.
jq, to pull fields out of JSON, anduuidgen.- An empty directory to work in. You do not need a checkout and you do not need Go: the compose
file names images a release published, for
linux/amd64andlinux/arm64, and pulls them.
Set up your machine gets you all of these on Linux, macOS with Colima, and Windows with WSL2, and ends with a check that a container can reach the model.
1. Pull the model
With Ollama running as Set up your machine describes for your system:
ollama pull qwen3.6:35b-a3b-mxfp8
docker run --rm --add-host host.docker.internal:host-gateway curlimages/curl:8.11.1 \
-sS --max-time 5 http://host.docker.internal:11434/v1/models | jq -r '.data[].id'
You should see the model you pulled, among any others you have:
qwen3.6:35b-a3b-mxfp8
Taisce uses this model to read each conversation turn and propose facts. The second command asks for the list from inside a throwaway container, which is the way Taisce's worker reaches the model, so a model listed here is one Taisce can use.
Which model?
Taisce asks the model for strict JSON built from a fixed list of relations, and not every model can keep to that. These were measured against the same 19-message test corpus (extraction models, 2026-09-11):
| Model | Where it runs | Keeps to the format? | Set it up with |
|---|---|---|---|
qwen3.6:35b-a3b-mxfp8 | Ollama, on your machine | Yes, all 19 cases | the steps above (the default) |
Qwen/Qwen3.8-27B, thinking off | vLLM, on a rented GPU | Yes, all 19 cases | demo-qwen3.8 |
deepseek-flash | the provider's hosted API | Yes, all 19 cases | deepseek |
qwen3.8:27b-mxfp8, thinking on | Ollama, on your machine | No answer: every call ran past the two-minute limit | not recommended |
With a hosted model, what people tell your agent leaves your machine and goes to that provider. Taisce only sends text to hosts you have listed in the allowlist, so that is always your decision.
A model that is not in this table may still work. Check it with make test-inference before you
rely on it.
Didn't work? If curl prints Failed to connect, the container cannot reach Ollama: either it
is not running, or it listens where a container cannot reach it.
When the check fails says which, per system. If the
model answers but nothing ever forms, see
memory isn't forming.
To use a hosted model instead, see reaching a model.
2. Start Taisce
Fetch the compose file for the release you want, then bring it up:
curl -fsSLO https://raw.githubusercontent.com/ensera-ai/taisce/v0.3.2/compose.yaml
docker compose up -d
docker compose ps -a --format '{{.Service}}\t{{.State}}\t{{.Health}}'
curl -s localhost:8080/ready
The first run pulls two images — the service and its PostgreSQL substrate — so give it a minute on a cold cache. Once the health checks pass, you should see:
api running healthy
bootstrap exited
manage running healthy
postgres running healthy
worker running healthy
{"status":"ready"}
Then check that the worker found a model it is allowed to use:
docker compose logs --no-log-prefix worker | grep -c 'MEMORY WILL NOT FORM'
0
A one-off bootstrap set up PostgreSQL, created a project called default and printed its tokens.
Now the api answers on port 8080, and the worker turns stored turns into facts in the
background.
Didn't work? If port 8080 is taken, run TAISCE_PORT=18080 docker compose up -d and use that
port below. If the count is 1 or more, see
memory never forms.
If api or worker keeps restarting, see
the serving process exits at start.
3. Get your token
docker compose logs --no-log-prefix bootstrap | grep 'token:'
You should see two tokens:
operator token: tsk_…
token: tsk_…
Save the second one, the project token, and try it:
export TOKEN=$(docker compose logs --no-log-prefix bootstrap | awk '/^token:/ {print $2}')
curl -sS localhost:8080/v1/freshness -H "Authorization: Bearer $TOKEN"
{"scope":"default","stored":null,"formed":null,"parked":0}
The project token opens memory for the default project, and null means nothing has been written
yet. The operator token is for running the instance and is refused on memory calls.
Didn't work? A 401 means the token is wrong, and the usual cause is picking up the operator
token by mistake (every call answers 401).
Tokens are printed once, on the first start. If the line is gone, issue a new one with
docker compose exec manage /taisce credential issue app --project default.
4. Save a memory
export KEY=$(uuidgen)
curl -sS -X POST localhost:8080/v1/observations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @- <<EOF
{"idempotency_key": "$KEY",
"data_subject_id": "alice",
"messages": [{"role": "user", "content": "I work at Ensera and I live in Dublin."}]}
EOF
You should see a receipt, with HTTP status 201:
{"id":"…","scope":"default","log_offset":0}
Taisce stored Alice's turn and answered straight away, before any model looked at it. log_offset
is the turn's place in the project's log, and data_subject_id says the words are Alice's. Run the
same command again and you get the same receipt: the idempotency_key makes it a retry, not a
second turn.
Didn't work? A 400 invalid_turn says what is wrong in its message, such as a key that is not
a UUID. A 409 idempotency_conflict means the key was already used for something else, so make a
new one with uuidgen (409 idempotency_conflict).
5. Wait for it to form
while :; do
F=$(curl -sS localhost:8080/v1/freshness -H "Authorization: Bearer $TOKEN"); echo "$F"
case "$F" in *'"formed":null'*) sleep 2 ;; *) break ;; esac
done
You should see a few lines while the model works, then formed catches up:
{"scope":"default","stored":0,"formed":null,"parked":0}
{"scope":"default","stored":0,"formed":null,"parked":0}
{"scope":"default","stored":0,"formed":0,"parked":0}
The worker had the model read Alice's message and kept only the facts whose words it could find in
it. stored is the last turn Taisce holds, and formed is the last turn it has turned into facts,
so your turn is in memory once formed reaches its log_offset
(stored and formed).
Didn't work? If formed stays null, see
formed stays null. If the loop ends with "parked":1,
the worker gave up on the turn after several tries; see
turns are parked.
6. Ask a question
curl -sS -X POST localhost:8080/v1/recalls \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"question": "Where do I work?", "data_subject_id": "alice"}' \
| jq '{anchors, facts: [.facts[] | {predicate, object, quote: .evidence.quote}]}'
You should see Alice's facts, each with the words it came from (trimmed; the statements come from the model, so wording can vary):
{
"anchors": [{"entity_id": "…", "name": "…", "type": "…", "matched": "speaker"}],
"facts": [
{"predicate": "works_at", "object": "Ensera", "quote": "I work at Ensera"},
{"predicate": "lives_in", "object": "Dublin", "quote": "…"}
]
}
Because you named Alice as the data_subject_id, "I" meant Alice, so recall started from her and
returned her facts, without calling a model. Without a subject, ask by name instead, as in "What do
we know about Ensera?".
Didn't work? An empty facts usually means the subject is spelled differently from the one you
saved (Alice is not alice) or formed has not reached your turn yet
(recall returns nothing for "I").
7. Forget Alice
curl -sS -X POST localhost:8080/v1/erasures \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data_subject_id": "alice", "reason": "right to erasure"}' | jq
You should see a receipt (trimmed; which kinds appear depends on what formed):
{
"request_id": "…",
"scope": "default",
"data_subject_id": "alice",
"completed_at": "…",
"deleted": {"observation": 1, "fact": 2, "entity": …, "chunk": 1, …},
"residual": {"fact": 0, "entity": 0, "chunk": 0, …},
"clean": true
}
Ask again:
curl -sS -X POST localhost:8080/v1/recalls \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"question": "Where do I work?", "data_subject_id": "alice"}' | jq '.facts'
[]
Taisce deleted Alice's turn and everything built only from it. Then, in the same transaction, it
counted what still matched her: that count is residual, and clean is true because every count
is zero.
Didn't work? A 400 no_subject means the body named nobody, and an erasure never defaults to the
whole project. A receipt with clean: false names the kind of thing that survived; the
forget a person recipe explains how to read one, and
troubleshooting covers the rest of a first run.
8. Give an agent memory
Steps 1 to 7 called the API yourself. An adapter makes the same calls from inside your agent framework: before the model answers, it recalls what Taisce knows about the person and adds it to the request as one message marked untrusted; after the model answers, it saves the turn. Adapters explains what every adapter holds to.
Each example below is the same small program in a different framework. It takes a message on the
command line, runs one turn for alice, prints the reply and exits. You run it twice, and the second
run is a new process with no chat history, so whatever it knows from the first came from Taisce.
Keep Taisce running from step 2. In the terminal where you run the program, set where Taisce is and which model the agent talks to:
export TAISCE_API=http://localhost:8080
export TAISCE_TOKEN=$TOKEN # the project token from step 3
export MODEL_BASE_URL=http://localhost:11434/v1 # any OpenAI-compatible endpoint
export MODEL_NAME=qwen3.6:35b-a3b-mxfp8
export MODEL_API_KEY=unused # Ollama ignores it; a hosted provider needs its key
The agent's model answers the person; Taisce's model, from step 1, reads what was said. They can be the same model, as here, or different ones.
Between the two runs, wait for the first turn to form, as in step 5. This waits until formed has
caught up with stored:
wait_formed() {
until curl -sS "$TAISCE_API/v1/freshness" -H "Authorization: Bearer $TAISCE_TOKEN" \
| jq -e '.formed != null and .formed >= .stored' >/dev/null; do sleep 2; done
}
Pick the framework you use. Microsoft Agent Framework comes first, in Python and in .NET.
Python: Microsoft Agent Framework
TaisceContextProvider is a context provider, the framework's own hook for adding to a request.
-
Install the adapter and the framework's OpenAI-compatible client. You need Python 3.10 or newer.
python -m venv .venv && . .venv/bin/activatepip install taisce-agent-framework agent-framework-openai -
Save this as
agent.py:import asyncioimport osimport sysimport uuidfrom agent_framework import Agentfrom agent_framework.openai import OpenAIChatCompletionClientfrom taisce import Clientfrom taisce_agent_framework import TaisceContextProviderdef report(stage: str, exc: Exception) -> None:print(f"taisce {stage} failed: {exc!r}", file=sys.stderr)async def main(message: str) -> None:model = OpenAIChatCompletionClient(os.environ["MODEL_NAME"],base_url=os.environ["MODEL_BASE_URL"],api_key=os.environ["MODEL_API_KEY"],)async with Client(os.environ["TAISCE_API"], os.environ["TAISCE_TOKEN"]) as taisce:memory = TaisceContextProvider(taisce,data_subject_id="alice", # whose memory this isrun_id=str(uuid.uuid4()), # this conversationon_error=report,)agent = Agent(model, context_providers=[memory])response = await agent.run(message)print(response.text)asyncio.run(main(" ".join(sys.argv[1:]))) -
Run it twice:
python agent.py "I moved to Cork last month for a new job at Ensera."wait_formedpython agent.py "Where do I live now, and where do I work?"The first reply is whatever the model says to news; the second answers from memory (the wording will differ):
You live in Cork and work at Ensera.
.NET: Microsoft Agent Framework
TaisceContextProvider is an AIContextProvider, the framework's own hook for adding to a request.
-
Make a console app and add the adapter and an OpenAI-compatible chat client. You need the .NET 10 SDK.
dotnet new console -n MemoryAgent && cd MemoryAgentdotnet add package Taisce.AgentFramework --version 0.1.1dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.10.0 -
Replace
Program.cswith:using System.ClientModel;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using OpenAI;using Taisce;using Taisce.AgentFramework;string Env(string name) => Environment.GetEnvironmentVariable(name)?? throw new InvalidOperationException($"{name} is not set");IChatClient model = new OpenAIClient(new ApiKeyCredential(Env("MODEL_API_KEY")),new OpenAIClientOptions { Endpoint = new Uri(Env("MODEL_BASE_URL")) }).GetChatClient(Env("MODEL_NAME")).AsIChatClient();using var taisce = new TaisceClient(Env("TAISCE_API"), Env("TAISCE_TOKEN"));var memory = new TaisceContextProvider(taisce, new TaisceContextProviderOptions{DataSubjectId = "alice", // whose memory this isRunId = Guid.NewGuid().ToString(), // this conversationOnError = (stage, ex) => Console.Error.WriteLine($"taisce {stage} failed: {ex.Message}"),});AIAgent agent = new ChatClientAgent(model, new ChatClientAgentOptions { AIContextProviders = [memory] });Console.WriteLine(await agent.RunAsync(string.Join(' ', args))); -
Run it twice:
dotnet run -- "I moved to Cork last month for a new job at Ensera."wait_formeddotnet run -- "Where do I live now, and where do I work?"You live in Cork and you work at Ensera.
The .NET guide adds the host-builder registration, compaction and saved sessions.
Python: LangGraph
LangGraph has no context provider; TaisceMemory is agent middleware for create_agent.
-
Install:
python -m venv .venv && . .venv/bin/activatepip install taisce-langgraph langchain-openai -
Save this as
memory_agent.py:import asyncioimport osimport sysimport uuidfrom langchain.agents import create_agentfrom langchain_core.messages import HumanMessagefrom langchain_openai import ChatOpenAIfrom taisce import Clientfrom taisce_langgraph import TaisceMemorydef report(stage: str, exc: Exception) -> None:print(f"taisce {stage} failed: {exc}", file=sys.stderr)async def main(text: str) -> None:model = ChatOpenAI(base_url=os.environ["MODEL_BASE_URL"],api_key=os.environ["MODEL_API_KEY"],model=os.environ["MODEL_NAME"],)async with Client(os.environ["TAISCE_API"], os.environ["TAISCE_TOKEN"]) as client:memory = TaisceMemory(client, data_subject_id="alice", run_id=f"run-{uuid.uuid4()}", on_error=report)agent = create_agent(model, tools=[], middleware=[memory])state = await agent.ainvoke({"messages": [HumanMessage(content=text)]})print(state["messages"][-1].content)asyncio.run(main(" ".join(sys.argv[1:])))The middleware is async only, so the agent runs with
ainvoke. -
Run it twice:
python memory_agent.py "I moved to Cork last month for a new job at Ensera."wait_formedpython memory_agent.py "Where do I live now, and where do I work?"You live in Cork and work at Ensera.
Java: Spring AI
TaisceMemoryAdvisor is an advisor on a ChatClient. This example builds the client in a plain
main; in a Spring Boot application the ChatClient.Builder comes from your model starter instead.
You need Java 21 and Maven.
-
Make a project with this
pom.xml. The adapter leaves the Spring AI version to you, so the framework is listed next to it:<project xmlns="http://maven.apache.org/POM/4.0.0"><modelVersion>4.0.0</modelVersion><groupId>example</groupId><artifactId>memory-agent</artifactId><version>1.0</version><properties><maven.compiler.release>21</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties><dependencies><dependency><groupId>ai.ensera.taisce</groupId><artifactId>taisce-spring-ai</artifactId><version>0.1.1</version></dependency><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-client-chat</artifactId><version>2.0.1</version></dependency><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai</artifactId><version>2.0.1</version></dependency></dependencies><build><plugins><plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version><configuration><mainClass>MemoryAgent</mainClass></configuration></plugin></plugins></build></project> -
Save this as
src/main/java/MemoryAgent.java:import ai.ensera.taisce.client.TaisceClient;import ai.ensera.taisce.springai.TaisceMemoryAdvisor;import java.util.Map;import java.util.UUID;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.openai.OpenAiChatModel;import org.springframework.ai.openai.OpenAiChatOptions;public class MemoryAgent {public static void main(String[] args) {OpenAiChatModel model = OpenAiChatModel.builder().options(OpenAiChatOptions.builder().baseUrl(System.getenv("MODEL_BASE_URL")).apiKey(System.getenv("MODEL_API_KEY")).model(System.getenv("MODEL_NAME")).build()).build();TaisceClient taisce = new TaisceClient(System.getenv("TAISCE_API"), System.getenv("TAISCE_TOKEN"));ChatClient chat = ChatClient.builder(model).build();var memory = new TaisceMemoryAdvisor(taisce,"alice", // whose memory this is"run-" + UUID.randomUUID(), // this conversationMap.of(), // recall options; empty uses the server's defaults(stage, e) -> System.err.println("taisce " + stage + " failed: " + e.getMessage()));String reply = chat.prompt().user(String.join(" ", args)).advisors(memory).call().content();System.out.println(reply);}} -
Run it twice:
mvn -q compile exec:java -Dexec.args="I moved to Cork last month for a new job at Ensera."wait_formedmvn -q compile exec:java -Dexec.args="Where do I live now, and where do I work?"You live in Cork and work at Ensera.Maven may first print
SLF4J(W): No SLF4J providers were found: no logging backend is on the classpath, and nothing else is wrong.
Java: LangChain4j
TaisceChatModel wraps the ChatModel you already have, because LangChain4j reads its chat memory
before the new message is in it.
-
Make a project with the
pom.xmlfrom Spring AI above, with these dependencies in place of its<dependencies>:<dependencies><dependency><groupId>ai.ensera.taisce</groupId><artifactId>taisce-langchain4j</artifactId><version>0.1.1</version></dependency><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j</artifactId><version>1.20.0</version></dependency><dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-open-ai</artifactId><version>1.20.0</version></dependency></dependencies> -
Save this as
src/main/java/MemoryAgent.java:import ai.ensera.taisce.client.TaisceClient;import ai.ensera.taisce.langchain4j.TaisceChatModel;import dev.langchain4j.memory.chat.MessageWindowChatMemory;import dev.langchain4j.model.chat.ChatModel;import dev.langchain4j.model.openai.OpenAiChatModel;import dev.langchain4j.service.AiServices;import java.util.Map;import java.util.UUID;public class MemoryAgent {interface Assistant {String chat(String message);}public static void main(String[] args) {ChatModel model = OpenAiChatModel.builder().baseUrl(System.getenv("MODEL_BASE_URL")).apiKey(System.getenv("MODEL_API_KEY")).modelName(System.getenv("MODEL_NAME")).build();TaisceClient taisce = new TaisceClient(System.getenv("TAISCE_API"), System.getenv("TAISCE_TOKEN"));ChatModel withMemory = new TaisceChatModel(model,taisce,"alice", // whose memory this is"run-" + UUID.randomUUID(), // this conversationMap.of(), // recall options; empty uses the server's defaults(stage, e) -> System.err.println("taisce " + stage + " failed: " + e.getMessage()));Assistant assistant = AiServices.builder(Assistant.class).chatModel(withMemory).chatMemory(MessageWindowChatMemory.withMaxMessages(20)).build();System.out.println(assistant.chat(String.join(" ", args)));}} -
Run it twice, the same way as Spring AI:
mvn -q compile exec:java -Dexec.args="I moved to Cork last month for a new job at Ensera."wait_formedmvn -q compile exec:java -Dexec.args="Where do I live now, and where do I work?"You live in Cork and work at Ensera.
The Java guide adds Spring Boot wiring, compaction and saved sessions.
Coding agents: MCP
A coding agent such as Claude Code needs no adapter: Taisce serves MCP at /mcp on the same port,
with the same token.
claude mcp add --transport http taisce http://localhost:8080/mcp \
--header "Authorization: Bearer $TAISCE_TOKEN"
claude mcp list
You should see:
taisce: http://localhost:8080/mcp (HTTP) - ✔ Connected
Claude Code keeps the header in its own configuration on your machine, not in your project. The MCP guide lists the six tools, and its Claude Code plugin adds what memory knows at the start of a session.
Didn't work? If the second run doesn't know about Cork, check that wait_formed returned before
it and that both runs name the same alice: the two runs are separate conversations, and only Taisce
connects them. If your error callback prints taisce recall failed or
taisce observe failed, the program can't reach TAISCE_API or the token is wrong
(every call answers 401).
When you are done
docker compose down stops everything. Your data stays in a Docker volume, so
docker compose up -d brings it back, and your token still works.
Next steps
- How it works: what happened in each step, in plain words.
- Examples: recipes for preferences, citations, forgetting, long conversations and coding agents.
- The HTTP API, by task: every call, option and error.
- Adapters, then the guide for Python, .NET or Java: options, compaction for long conversations, saved sessions and known limits.
- Troubleshooting: the problems a first run actually hits.