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.
Taisce needs a model to read conversations. The quickstart uses DeepSeek's hosted API: there is nothing to download or serve. To keep conversation text on your own infrastructure in production, serve Qwen with vLLM, as choosing a model describes.
What you need
- Docker with the Compose plugin, so
docker compose versionanswers. The older standalonedocker-composewill not do. - A DeepSeek API key. An OpenRouter key is only needed later, if you turn on semantic search.
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 Docker, jq and uuidgen on Linux, macOS
with Colima and Windows with WSL2.
1. Give Taisce a model
Taisce uses a model to read each conversation turn and propose facts. In the empty directory you will
run step 2 from, write your DeepSeek key and the model's settings to .env. Compose reads that file
from the directory on every command, so nothing has to stay exported:
export DEEPSEEK_API_KEY=… # from platform.deepseek.com
cat > .env <<EOF
TAISCE_INFERENCE_API_KEY=$DEEPSEEK_API_KEY
TAISCE_INFERENCE_ENDPOINT=https://api.deepseek.com/v1
TAISCE_INFERENCE_EXTRACTOR_MODEL=deepseek-flash
TAISCE_INFERENCE_ALLOWLIST=api.deepseek.com
EOF
chmod 600 .env
curl -sS https://api.deepseek.com/models -H "Authorization: Bearer $DEEPSEEK_API_KEY" | jq -r '.data[].id'
You should see the models your key can use:
deepseek-flash
deepseek-v4-pro
What this sends where. Every turn you save is sent to DeepSeek to be read. Only the hosts in
TAISCE_INFERENCE_ALLOWLIST ever receive text, so writing api.deepseek.com there is you choosing
that. Asking questions and erasing never call a model.
Semantic search, later. Forming facts and asking questions need no embedding model. When you want to search the words people said, add OpenRouter for embeddings (DeepSeek's API has none), list its host too, and build a generation as message embeddings describes:
export OPENROUTER_API_KEY=… # from openrouter.ai/keys
cat >> .env <<EOF
TAISCE_INFERENCE_EMBEDDING_ENDPOINT=https://openrouter.ai/api/v1
TAISCE_INFERENCE_EMBEDDING_MODEL=qwen/qwen3-embedding-4b
TAISCE_INFERENCE_EMBEDDING_API_KEY=$OPENROUTER_API_KEY
EOF
sed -i.bak 's/^TAISCE_INFERENCE_ALLOWLIST=.*/TAISCE_INFERENCE_ALLOWLIST=api.deepseek.com,openrouter.ai/' .env && rm .env.bak
Didn't work? A 401 from the curl means the key is wrong or not exported in this terminal.
.env holds that key now: keep it out of version control.
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 |
|---|---|---|---|
deepseek-flash | DeepSeek's hosted API | Yes, all 19 cases | step 1 |
Qwen/Qwen3.8-27B, thinking off | vLLM, on your own GPU | Yes, all 19 cases | choosing a model, for production |
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. If a model answers but nothing ever forms, see
memory isn't forming.
Choosing a model covers running Qwen models in
production.
2. Start Taisce
In the directory that holds .env, fetch the compose file for the release you want, then bring it
up:
curl -fsSLO https://raw.githubusercontent.com/ensera-ai/taisce/v0.5.1/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 accepted the model settings:
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.
The count checks the settings, not the key. A key the provider refuses shows up once you save a
memory, as a turns did not form warning with "status":401
(formed stays null).
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: it signs in to the ops centre
(step 9) 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=https://api.deepseek.com/v1 # any OpenAI-compatible endpoint
export MODEL_NAME=deepseek-flash
export MODEL_API_KEY=$DEEPSEEK_API_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.2dotnet 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.2</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.2</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).
9. Open the ops centre
The ops centre is the operator's view of the instance: projects, credentials, formation health, parked turns and erasure receipts. It shows counts and reasons, never anything anybody said. It is off until you switch it on, so a browser surface is never exposed by accident. In the directory from step 2:
echo 'TAISCE_PORTAL=on' >> .env
docker compose up -d
docker compose logs --no-log-prefix bootstrap | grep 'operator token:'
Open http://127.0.0.1:8081/portal/ and paste the operator token. Only an operator token signs in:
the project token from step 3 is refused. The port listens on this machine only.
Didn't work? A 404 on /portal/ means TAISCE_PORTAL is not on in the .env beside
compose.yaml. If the token line is gone, mint another with
docker compose exec manage /taisce operator issue <your-name>.
Operate an instance covers what the ops centre shows and does.
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.
The install belongs to this directory: Compose names it after the directory, so run these commands from here. Starting the quickstart again in another directory gives a second, separate install.
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.