Ekko Agent is a standalone TypeScript agent runtime used by Ekko Studio. It owns the model loop, provider adapters, tools, memory, skills, delegation, approvals, and structured request logging.
The recommended entry is a Profile-oriented container: new EkkoAgent()
creates one EkkoProfileAgent per configured Profile, so modules are used as
ekko.default.skill, ekko.default.memory, or
ekko.agent.get('work').tool. See the complete field, method, parameter, and
configuration reference in docs/API.md.
default is always created, even when profiles is omitted or does not list
it. Existing first-level Profile directories under .ekko/skills,
.ekko/logs, and .ekko/workspace are discovered automatically; explicit
profiles are merged with those names and missing managed directories are
created before the Profile Agent is validated.
The first implemented layer is model-provider requests. Internally, the agent uses one request shape and provider adapters translate it to external APIs.
Supported request styles:
- OpenAI Chat Completions style
- OpenAI-compatible providers such as DeepSeek, Qwen, Moonshot, Ollama
- OpenAI Responses
- Anthropic Messages
- Gemini Contents
- prompt completion
- custom runtime
First-class OAuth provider presets:
nous— OpenAI Chat Completions at the Nous inference APIopenai-codex— OpenAI Responses at the ChatGPT Codex backendxai-oauth— OpenAI Responses at the xAI APIqwen-oauth— OpenAI Chat Completions at the Qwen Portal APIclaude-oauth— Anthropic Messages at the Anthropic APIminimax-oauth— Anthropic Messages for the MiniMax Coding Plan
Callers still own the interactive login flow, then store its result through
setModelAuthorization. Ekko persists and refreshes those credentials before
requests through a supplied provider refresher or a standard configured OAuth
token endpoint. Passing apiKey remains available as a one-process override.
MiniMax Coding Plan requests use Bearer-only authentication and do not send
x-api-key.
Default endpoints:
| Style | Default endpoint |
|---|---|
openai-chat |
https://api.openai.com/v1/chat/completions |
openai-responses |
https://api.openai.com/v1/responses |
anthropic-messages |
https://api.anthropic.com/v1/messages |
gemini-contents |
https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent |
prompt-completion |
https://api.openai.com/v1/completions |
custom-runtime |
http://127.0.0.1:11434/v1/agent |
Use baseUrl and endpointPath to override these defaults.
All adapters receive the same internal message shape:
type AgentMessage = {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
name?: string
toolCallId?: string
toolCalls?: AgentToolCall[]
}Use normalizeAgentMessage() or normalizeAgentMessages() at the boundary.
Model responses can be converted back to a single assistant message with
modelResponseToAgentMessage(). Streaming events can be collected into the same
shape with collectModelEvents().
Built-in tools:
clarifyasks one blocking user question, with optional answer choices, when the host provides an interactive clarification handler.read_filereads a text file.view_imageloads a local PNG, JPEG, WebP, or GIF as multimodal model input while enforcing the runtime workspace boundary and a size limit.write_filewrites text content and creates parent directories by default.terminal_execruns a command with an argument array andshell: false.code_execruns a one-shot Node.js or Python script. Scripts can call the allowedread_file,write_file, andterminal_exectools through the generatedekko_tools.mjsorekko_tools.pyRPC bridge. Intermediate tool results remain inside the child script; only its reduced stdout is returned to the model.skill_listlists or searches skills under the agent's configuredskillDirectory. It returns names, concise descriptions, and built-in identity; maintained routing keywords remain internal to the host matcher.skill_viewloadsSKILL.mdor an allowed support file for one skill in that directory and returns the Skill's absolutebaseDirectoryso bundled scripts can be executed.skill_managecreates, patches, edits, archives, or manages support files whenskillDirectoryis configured. Created and updated skills require non-empty compact English ASCII frontmattermetadata.keywords; built-in skills cannot be deleted.
Use workspaceRoot to keep file and terminal working directories inside a
specific workspace.
import { createDefaultToolRegistry } from 'ekko-agent'
const tools = createDefaultToolRegistry()
await tools.execute('write_file', {
path: 'notes/todo.txt',
content: 'ship tools',
}, {
workspaceRoot: process.cwd(),
})
const result = await tools.execute('terminal_exec', {
command: 'node',
args: ['-v'],
}, {
workspaceRoot: process.cwd(),
})
const batchResult = await tools.execute('code_exec', {
language: 'node',
code: `
import { read_file } from './ekko_tools.mjs'
const result = await read_file({ path: 'README.md' })
console.log(result.content.split('\\n').slice(0, 5).join('\\n'))
`,
}, {
workspaceRoot: process.cwd(),
})code_exec accepts language: "node" or language: "python", runs for at
most the configured tool execution timeout, permits at most 50 nested tool
calls, caps stdout at 50KB, scrubs the child environment, and rejects recursive
or non-allowlisted tool calls.
Dangerous tools are authorized before execution. code_exec always requires
authorization because ordinary Node.js and Python source can access the host;
terminal_exec requires authorization for destructive, privileged,
remote-shell, package-publishing, service-control, and similar commands. The
available decisions are once, session, always, and deny. A session
decision stays in process memory for the matching chat session. An always
decision is stored in the global config under
tools.approvals.permanentAllow; denial fails closed before the tool starts.
The host supplies requestToolApproval in the per-run tool context to bridge
these decisions into its UI.
AgentRuntime ties messages, model requests, tools, skills, system prompt, and
events together. The default maxSteps is 90, matching Hermes' regular agent
turn budget.
Tools execute serially unless their AgentTool.concurrency is explicitly set
to parallel. Consecutive parallel-safe calls run with an eight-call limit;
serial tools remain ordered barriers, and results are replayed to the model in
the original tool-call order. Built-in file/image/skill/memory reads opt in.
MCP tools opt in per server with supports_parallel_tool_calls: true.
The default registry exposes clarify only for a foreground run whose
AgentToolContext provides requestUserClarification. Delegated subagents and
non-interactive hosts do not receive the tool. When available, the runtime
prompt requires blocking clarification questions to use the tool instead of
being returned as an ordinary assistant response.
When Ekko runs inside a host that owns conversation persistence, the host also
owns context compression. estimateContext() exposes the provider-visible
system, tool, message, and provider-context estimate needed for that external
threshold decision without starting a model call. A standalone Ekko host can
instead implement and own its internal compression lifecycle.
Model context and memory evidence are separate inputs. A host that adds derived
summaries, retrieved context, routing instructions, or other application-owned
content to the model input should pass only its trusted conversation evidence
through memoryInput.messages. The optional memoryInput.reviewPolicy lets the
host choose automatic review or explicit-request-only writes without teaching
the Ekko runtime about host-specific product concepts. A host can also attach
an opaque memoryInput.origin and declare recallScopes, writeScopes, and a
defaultWriteScope. Ekko understands only the generic profile, context, and
session scope shapes; identifiers and namespaces belong to the host. Calls
that omit scope configuration remain backward compatible and can access only
profile-scoped memory.
The foreground agent can search and inspect authorized memory, but durable
writes are performed only by the isolated post-run curator over the trusted
memoryInput.messages. This prevents derived model context from bypassing the
host's evidence and scope boundary. For an explicit remember, correction,
update, or forget request, the runtime forces a visible foreground
memory_review tool call first. That tool carries no proposed memory content;
it only requests immediate isolated review, so visibility does not reopen the
write bypass. See
docs/MEMORY_HARNESS.md for the executable quality
contract.
Call new EkkoAgent() (or the compatible setupEkkoAgent()) once during host
startup, before accepting agent work.
The setup entry owns EkkoDirectoryManager, creates
<base>/.ekko/config/config.json, the skills, logs, and workspace directories,
and opens and migrates the SQLite database. Development uses the package-local
sql-data/ekko-agent.db; production uses <base>/.ekko/ekko.db. It returns
the shared database-backed memory and conversation stores and closes that
process-level resource through setup.close(). The global JSON file drives
runtime limits, model defaults and providers, tools, approvals, profile-scoped
MCP servers, delegation, memory, skills, logging, and prompt instructions. Configuration upgrades merge
new defaults one field at a time: existing user values, arrays, and unknown
forward-compatible fields are never replaced as a whole module. A configured
profile uses
<base>/.ekko/skills/<profile> for its skills and
<base>/.ekko/logs/<profile> for its log. Its default per-session workspace is
<base>/.ekko/workspace/<profile>/<session-id>; an explicitly supplied
workspaceRoot or cwd takes precedence. The server supplies its Web UI home as
the base directory. For compatibility, the server supplies the Hermes root during
initialization. If .ekko/skills does not exist yet, the manager imports
the default profile from <hermes>/skills and every named profile from
<hermes>/profiles/<profile>/skills. This is a one-time copy: once
.ekko/skills exists, later startups do not resync or overwrite Ekko-owned
skills imported from Hermes.
Each Profile may additionally reference read-only Skill roots through
skills.profiles.<profile>.externalDirectories; those directories stay in
place and are never copied into Ekko storage. disabled in the same Profile
entry contains Skill names hidden from prompt injection and automatic routing.
Local Skills take precedence over same-name external Skills. For example:
{
"skills": {
"enabled": true,
"reviewEveryToolCalls": 0,
"profiles": {
"work": {
"externalDirectories": ["~/shared-skills", "$TEAM_SKILLS"],
"disabled": ["weather"]
}
}
}
}Ekko's package-owned built-in skills are separate from that compatibility
import. Each Profile receives 1password, apple-notes, apple-reminders,
document-to-action-items, docx, gh-issues, github,
grok-image-to-video, hermes-studio-installation, image-gen,
node-inspect-debugger, obsidian,
ocr-and-documents, pdf, powerpoint, python-debugpy,
skill-creator, spike, tmux, video-frames, weather, and
xlsx. Startup
installs missing built-ins and updates only
an unchanged Ekko-installed copy. A user-edited or pre-existing same-name Skill
is never overwritten. image-gen and grok-image-to-video use Ekko Studio's
local media endpoints and require a matching configured Studio Profile. The
document Skills bundle their Python helpers, references, tests, and license
notices; optional Python, LibreOffice, Poppler, OCR, and model dependencies are
checked at use time rather than installed during Ekko startup.
import { EkkoAgent } from 'ekko-agent'
const ekko = new EkkoAgent({
baseDirectory: '/path/to/base',
profiles: ['work'],
})
ekko.config.setModelProvider('deepseek', {
type: 'openai-compatible',
requestStyle: 'openai-chat',
baseUrl: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat',
apiKey: 'sk-...',
})
ekko.config.setDefaultModel('deepseek')
const runtime = ekko.agent.get('work').runtime.create()
try {
const result = await runtime.run({
messages: ['Read README.md and summarize it.'],
toolContext: {
workspaceRoot: process.cwd(),
},
onEvent(event) {
console.log(event.type)
},
})
} finally {
ekko.close()
}Set toolsEnabled: false to omit all tool sources (built-ins, MCP, memory,
and skill tools). Set skillsEnabled: false to omit constructor and per-run
skills. The switches are independent and default to true.
new EkkoAgent(options) is the all-in-one public facade and multi-Profile
container. Every Profile gets an independent Agent instance with bound
skill, tool, memory, conversation, runtime, directory, and log
modules. Shared config, model Provider, authorization, and database modules are
also reachable through each Profile Agent. setupEkkoAgent(options) returns
the same facade for compatibility with the existing setup style.
setup.config is an EkkoConfigStore. It exposes read, update, replace,
reset, MCP server CRUD, provider-preset CRUD, configured-provider CRUD,
authorization CRUD, installModelProviderPreset, and setDefaultModel. Nested
update patches merge at field level, so changing runtime.maxSteps does not
replace other runtime settings. setup.modelProviderConfig() resolves the
active provider, and setup.createModelClient() creates a client without
starting a runtime.
MCP servers live in the same file under
mcp.profiles.<profile>.servers. Use setMcpServer, getMcpServer,
listMcpServers, and deleteMcpServer; newly created runtimes automatically
load the selected Profile's enabled servers. Local servers use command,
args, and env; remote servers use type: "streamable_http", url, and
optional string headers. Both transports run through the official MCP client.
The config contains a curated model.providerCatalog derived from Hermes
Studio. It includes common API-key providers and the nous, openai-codex,
xai-oauth, qwen-oauth, claude-oauth, and minimax-oauth authorization
providers. Every preset has an explicit apiMode; Ekko validates that its
adapter-level requestStyle matches instead of relying on endpoint inference.
API-key credentials live in the provider object under apiKey. OAuth state
lives under model.authorizations. The
.ekko/config/config.json file is created with user-only 0600 permissions.
A caller can still pass apiKey to setup.createModelClient() or
setup.createRuntime() to override the persisted value for one process.
setup.config.update({
runtime: { maxSteps: 60 },
model: { reasoningEffort: 'high', maxTokens: 8_192 },
tools: { codeExec: { enabled: false } },
})
const providers = setup.config.listModelProviders()
const client = setup.createModelClient({ provider: providers[0].id })Install and manage a built-in provider with the exported config-store methods:
const agent = new EkkoAgent({ baseDirectory: '/path/to/base' })
agent.installModelProviderPreset('openai-codex', {
defaultModel: 'gpt-5.6-terra',
})
agent.updateModelProvider('openai-codex', {
defaultModel: 'gpt-5.6-sol',
})
agent.deleteModelProvider('openai-codex')OAuth providers refresh before a request when their configured expiry is
within model.authorizationRefreshLeewayMs. Supply a provider-aware refresher
to setupEkkoAgent, or configure tokenUrl, refreshToken, and optional OAuth
client fields to use the exported standard refresh-token implementation.
Refreshes are deduplicated per provider, rotated credentials are persisted,
and refresh failures never fall back to an expiring stale token.
const setup = setupEkkoAgent({
authorizationRefresher: async ({ provider, authorization }) => {
const refreshed = await refreshProviderAuthorization(provider, authorization)
return {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
expiresAt: refreshed.expiresAt,
baseUrl: refreshed.baseUrl,
apiMode: refreshed.apiMode,
}
},
})
setup.config.setModelAuthorization('openai-codex', {
type: 'oauth',
accessToken,
refreshToken,
expiresAt,
})
await setup.authorizations.refresh('openai-codex')
setup.config.updateModelAuthorization('openai-codex', { refreshToken: rotatedToken })
setup.config.deleteModelAuthorization('openai-codex')The standalone database owns Studio-compatible sessions and messages
tables. setup.conversations exposes Session operations createSession,
getSession, listSessions, updateSession, renameSession,
setSessionArchived, endSession, reopenSession, deleteSession, and
getSessionDetail. Message operations are addMessage, addMessages,
getMessage, listMessages, updateMessage, deleteMessage, and
clearMessages. recordSessionUsage accumulates model usage independently of
message edits.
const session = setup.conversations.createSession({
profile: 'default',
provider: 'deepseek',
model: 'deepseek-chat',
title: 'Repository review',
})
setup.conversations.addMessage({
sessionId: session.id,
role: 'user',
content: 'Review this repository.',
})
setup.conversations.renameSession(session.id, 'Ekko repository review')
const history = setup.conversations.listMessages(session.id)setup.memory exposes the standalone memory API. Memory-node operations are
list, get, search, create, update, expire, delete, and forget.
Conversation-derived memory data can be read with listMessages,
getLatestSummary, getSessionState, and listAuditEvents. The lower-level
SQLite implementation remains available as setup.memoryStore.
Ordinary turns are reviewed in batches of eight by default, while explicit remember/forget requests and other high-signal durable statements are reviewed immediately. The curator follows the latest trusted user's language for memory cards and rolling summaries.
const created = await setup.memory.create({
kind: 'general_preference',
itemKey: 'interface_theme',
reason: 'User selected a persistent preference.',
explicitUserIntent: true,
identity: { sessionId: session.id, profileId: 'default' },
node: {
valueJson: 'dark',
title: 'Interface theme',
content: 'The user prefers a dark interface.',
},
})
const memories = await setup.memory.list({ profileId: 'default', limit: 20 })
const updated = await setup.memory.update(created.nodeId!, {
reason: 'User changed the preference.',
expectedRevision: created.node!.revision,
identity: { sessionId: session.id, profileId: 'default' },
node: { valueJson: 'light' },
})
// Soft delete is the default. Hard delete additionally requires confirmed: true.
await setup.memory.delete(updated.nodeId!, {
reason: 'User asked Ekko to forget it.',
expectedRevision: updated.node!.revision,
identity: { sessionId: session.id, profileId: 'default' },
})Ekko exposes skill_manage to the foreground agent when skillDirectory is
configured. Existing files must first be loaded through skill_view in the
same run. The mutation is rejected if the file changed after it was viewed.
Overwrites create a recoverable copy under
.ekko/skills/<profile>/.ekko-backups, while confirmed skill deletion moves
the directory under .ekko/skills/<profile>/.ekko-archive. Synchronized
built-in skills are identified by the Profile manifest and cannot be deleted.
Before the first model response, the runtime injects only the current Profile's
file-backed Skill names. The main model maps requests in any language to one of
those names and calls skill_view directly. The host also compares the latest
effective user message with Skill names and compact English
metadata.keywords; exact matches are preloaded deterministically through
skill_view, producing the same visible tool events as an ordinary Skill load.
Descriptions remain available to skill_list for fallback discovery, while
keywords remain host-only and are not returned by skill_list.
After 10 cumulative tool calls in one session, the runtime schedules a
background procedural review. The review uses a dedicated conservative prompt
and only skill_list, skill_view, and skill_manage; it does not block the
foreground response. It can create a reusable class-level skill, but can update
only skills marked as created by Ekko and cannot delete. Set
skillReviewEveryToolCalls: 0 to disable this review or provide another positive
threshold.
Each profile writes structured JSON Lines to one file:
.ekko/logs/<profile>/ekko-agent.jsonl. The file is capped at 10 MiB. When the
next event would exceed that cap, the existing content is discarded and
logging continues in the same file; no rotated or per-session files are
created.
The persistent log is intentionally request-only. Every model-client request
attempt writes one terminal model.request record after it completes or fails.
That single record combines safe request metadata, status, duration, usage, and
response sizes. Runtime events, streaming deltas, tool events, prompts, and
response bodies are not written, so log volume tracks model calls instead of
the much larger runtime event stream.
Endpoints and common credential shapes are redacted, large strings are
truncated, and base64 payloads are omitted. EkkoFileLogReader.query() can
filter the current file by session, run, turn, category, level, event, time, or
text without acquiring write ownership. The Hermes Web UI Logs page exposes the
same file as the ekko-agent source for the selected profile.
npm install
npm run check
npm test
npm run buildimport { createModelClient } from 'ekko-agent'
const client = createModelClient({
id: 'deepseek',
type: 'openai-compatible',
apiKey: process.env.DEEPSEEK_API_KEY,
baseUrl: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat',
})
const response = await client.create({
messages: [{ role: 'user', content: 'Say hello.' }],
})