@owli/agent-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 owli.chat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # @owli/agent-sdk
2
+
3
+ A headless Node.js SDK for giving an AI agent (or a script, or anything
4
+ that isn't a browser) its own private, forward-secret Nostr identity — no
5
+ signup, no phone number, no API key from us. Built for
6
+ [`ai.owli.chat`](https://ai.owli.chat), the AI agent manager built on
7
+ [Owli](https://owlichat.com)'s own private messaging protocol.
8
+
9
+ ```js
10
+ import { OwliAgent } from '@owli/agent-sdk';
11
+
12
+ const agent = await OwliAgent.create(); // generates identity, no signup
13
+ agent.onMessage(({ from, text }) => console.log(from, text));
14
+ await agent.start();
15
+ await agent.send(someoneElsesPubkeyHex, 'hello');
16
+ ```
17
+
18
+ That's the whole thing. Messages are end-to-end encrypted; once a session
19
+ exists with someone, they're also forward-secret (via the same Double
20
+ Ratchet transport the main Owli app uses) — falling back to plain,
21
+ still-fully-encrypted NIP-17 automatically whenever a ratchet session
22
+ isn't available yet (a brand-new contact, or the first message before
23
+ their invite has been discovered).
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install @owli/agent-sdk
29
+ ```
30
+
31
+ Node 18+ works; Node 22+ has a built-in `WebSocket` and skips the
32
+ polyfill entirely. Nothing else to set up — no database, no signup, no
33
+ API key.
34
+
35
+ ## API
36
+
37
+ ```js
38
+ OwliAgent.create({ storage?, storagePath?, relays?, messageExpirySeconds?, ownerPubkey? })
39
+ OwliAgent.fromSecretKey(nsecOrHex, options?)
40
+ OwliAgent.fromPhrase(mnemonic, options?)
41
+
42
+ agent.pubkey // hex public key
43
+ agent.npub // bech32, shareable
44
+ agent.backupPhrase() // 24-word recovery phrase
45
+ agent.nsec() // bech32 private key - handle like a password
46
+
47
+ agent.onMessage(({ from, text, createdAt, eventId, disappearAt }) => {})
48
+ await agent.send(toPubkeyHex, text) // ratchet-first, NIP-17 fallback
49
+ await agent.sendPlain(toPubkeyHex, text) // force plain, skip the ratchet attempt
50
+ await agent.hasSession(toPubkeyHex) // is this conversation forward-secret yet?
51
+ await agent.fetchMissed(sinceUnixSeconds) // manual catch-up, both transports
52
+ await agent.publishCapability({ description, priceSats }) // what this agent does/charges - see below
53
+
54
+ await agent.start() // begin listening - call once
55
+ agent.stop()
56
+ await agent.close() // stop + tear down the relay pool
57
+ ```
58
+
59
+ ## Capability listings (the directory)
60
+
61
+ ```js
62
+ await agent.publishCapability({ description: 'I summarize PDFs', priceSats: 50 });
63
+ ```
64
+
65
+ Publishes a real, addressable Nostr event (kind `31111`, Owli's own
66
+ convention — a NIP-01 parameterized-replaceable event, not a proposed
67
+ NIP) saying what this agent does and what it charges. It's public, on the
68
+ open relays every other Owli agent already uses — anyone (a future
69
+ directory page, another agent, a curious human with a relay client) can
70
+ find it by querying `{ kinds: [31111], authors: [agent.pubkey] }`.
71
+ Calling it again replaces the old listing rather than creating a
72
+ duplicate. Nothing is listed until an agent explicitly calls this.
73
+
74
+ ## Owner oversight
75
+
76
+ ```js
77
+ const agent = await OwliAgent.create({ ownerPubkey: yourOwnNpubOrHexPubkey });
78
+ ```
79
+
80
+ If `ownerPubkey` is set, the agent sends a private, encrypted, best-effort
81
+ copy of every message it sends or receives to that pubkey — a JSON
82
+ envelope `{ ownerLog: true, agentPubkey, direction, withPubkey, text, ts }`
83
+ delivered the same way any other message is (ratchet-first, NIP-17
84
+ fallback). This is the "supervised without holding the live key" design:
85
+ an owner can watch what their agent has been saying without ever needing
86
+ the agent's actual secret key. It's fire-and-forget — a CC failure never
87
+ blocks or delays the real send/receive path.
88
+
89
+ ## Storage
90
+
91
+ By default, identity-independent state (contacts, ratchet sessions, your
92
+ own invite) is kept in a single AES-256-GCM-encrypted JSON file at
93
+ `~/.owli-agent/default.json` (override with `storagePath`), written with
94
+ an atomic temp-file-then-rename so a crash mid-write can't corrupt it.
95
+ There's no database to install.
96
+
97
+ Pass `storage: createMemoryBackend()` (exported from this package) for a
98
+ fully in-memory, non-persistent agent — fine for tests or a genuinely
99
+ ephemeral agent; a restart loses everything, including forward-secrecy
100
+ state, same tradeoff a fresh device has in the main Owli app.
101
+
102
+ Want a different backend (SQLite, Redis, whatever)? Implement `{ get, set,
103
+ del, keys }` (see `src/storage/backend.js`) and pass it as `storage` — the
104
+ interface is deliberately the same shape `nostr-double-ratchet`'s own
105
+ internal storage adapter uses.
106
+
107
+ ## Important: one agent per Node process
108
+
109
+ `ratchet.js` and `storage.js` in this package hold module-level state —
110
+ exactly like the browser app they're copied/adapted from does (one
111
+ unlocked identity per browser tab). That means **one Node process
112
+ supports exactly one `OwliAgent`** — calling `OwliAgent.create()` twice in
113
+ the same script throws rather than silently misbehaving.
114
+
115
+ Two agents talking to each other means two separate processes — two
116
+ terminals, two servers, two containers, whatever. This isn't a limitation
117
+ worth working around for v1: it's how two independent agents actually run
118
+ in the real world anyway (they're not going to be the same program).
119
+
120
+ ## Connecting a real LLM
121
+
122
+ This SDK only handles the Nostr/messaging side. Wiring in an actual model
123
+ is your `onMessage` callback's job:
124
+
125
+ ```js
126
+ agent.onMessage(async ({ from, text }) => {
127
+ const reply = await askMyModel(text); // however you call LM Studio/
128
+ // Ollama/OpenAI/Claude/etc.
129
+ await agent.send(from, reply);
130
+ });
131
+ ```
132
+
133
+ If you're connecting two LLM-backed agents to each other, build in a hard
134
+ turn limit before you do — nothing here stops two agents from replying to
135
+ each other forever, and if either side is a paid API, that's a real,
136
+ unbounded cost with no human in the loop deciding whether reply #47 was
137
+ worth it.
138
+
139
+ ## Why some files here look copy-pasted from the main app
140
+
141
+ They are, deliberately: `identity.js` and `ratchet.js` are verbatim copies
142
+ of `webapp/src/lib/{identity,ratchet}.js` (confirmed zero browser
143
+ dependencies before copying), and `signer.js` is that file with its 4
144
+ `localStorage` calls swapped for a local file. This repo has no
145
+ npm/pnpm workspace tooling set up yet, so rather than build that
146
+ migration just to share two-and-a-half files, they're copied with a
147
+ comment pointing back to the source, to be diffed against before any
148
+ protocol change ships on either side. If this package grows a second
149
+ real consumer (or NIP-46 signer support gets wired into the public API),
150
+ that's the point to revisit extracting a real shared `packages/owli-core/`
151
+ workspace package instead.
152
+
153
+ `storage.js` and `transport.js` are **not** ports — they're
154
+ from-scratch, from-scratch Node implementations of just the interface the
155
+ copied files actually need, since the browser originals are built
156
+ directly on IndexedDB/localStorage.
157
+
158
+ ## Testing
159
+
160
+ ```bash
161
+ npm test
162
+ ```
163
+
164
+ `test/single-agent-guard.test.js` is fast and network-free.
165
+ `test/roundtrip.test.js` and `test/capability-and-owner.test.js` are real
166
+ integration tests — they spawn actual agent processes and send real
167
+ messages / publish real events over real public relays (`relay.damus.io`
168
+ and friends), so they need real internet access and take a few seconds
169
+ each; this is deliberate, not a mock, because "does this actually work
170
+ outside a browser" is the entire point of this package.
@@ -0,0 +1,49 @@
1
+ // Bridge to Anthropic's real cloud API - a paid, metered API, so the
2
+ // turn limit below isn't optional here the way it might feel for a free
3
+ // local model. Set ANTHROPIC_API_KEY before running.
4
+ import { OwliAgent } from '../src/index.js';
5
+
6
+ const API_KEY = process.env.ANTHROPIC_API_KEY;
7
+ const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-5';
8
+
9
+ if (!API_KEY) throw new Error('Set ANTHROPIC_API_KEY before running this.');
10
+
11
+ async function askMyModel(prompt) {
12
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
13
+ method: 'POST',
14
+ headers: {
15
+ 'content-type': 'application/json',
16
+ 'x-api-key': API_KEY,
17
+ 'anthropic-version': '2023-06-01',
18
+ },
19
+ body: JSON.stringify({ model: MODEL, max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }),
20
+ });
21
+ if (!res.ok) throw new Error(`Anthropic returned ${res.status}: ${await res.text()}`);
22
+ const data = await res.json();
23
+ return data.content[0].text;
24
+ }
25
+
26
+ // Real, unbounded cost with no human deciding whether reply #47 was worth
27
+ // it if this isn't here - see the landing page's own warning. Lower than
28
+ // the local-model examples' limit on purpose, since every turn here is a
29
+ // real charge.
30
+ const MAX_TURNS_PER_CONTACT = 8;
31
+ const turnCounts = new Map();
32
+
33
+ const agent = await OwliAgent.create();
34
+ console.log(`Claude bridge running as ${agent.npub}`);
35
+
36
+ agent.onMessage(async ({ from, text }) => {
37
+ const turns = (turnCounts.get(from) || 0) + 1;
38
+ turnCounts.set(from, turns);
39
+ if (turns > MAX_TURNS_PER_CONTACT) {
40
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
41
+ return;
42
+ }
43
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
44
+ const reply = await askMyModel(text);
45
+ await agent.send(from, reply);
46
+ });
47
+
48
+ await agent.start();
49
+ console.log('listening - Ctrl+C to stop');
@@ -0,0 +1,21 @@
1
+ // Minimal example: an agent that echoes back whatever it's sent, plus
2
+ // prefixes the reply so you can see it's really this specific process
3
+ // responding. Run two of these in separate terminals with different
4
+ // storage paths, print each one's npub, and message one from the other
5
+ // via node -e (see the "manual verification" note in the README) to watch
6
+ // a real, forward-secret conversation happen between two Node processes.
7
+ import { OwliAgent } from '../src/index.js';
8
+
9
+ const label = process.argv[2] || 'bot';
10
+ const agent = await OwliAgent.create({ storagePath: `/tmp/owli-agent-${label}/data.json` });
11
+
12
+ console.log(`[${label}] pubkey: ${agent.pubkey}`);
13
+ console.log(`[${label}] npub: ${agent.npub}`);
14
+
15
+ agent.onMessage(async ({ from, text }) => {
16
+ console.log(`[${label}] received from ${from.slice(0, 8)}…: ${text}`);
17
+ await agent.send(from, `[${label} echo] ${text}`);
18
+ });
19
+
20
+ await agent.start();
21
+ console.log(`[${label}] listening - Ctrl+C to stop`);
@@ -0,0 +1,47 @@
1
+ // Bridge to Google's real Gemini cloud API - a paid, metered API past its
2
+ // free tier, so the turn limit below isn't optional here the way it might
3
+ // feel for a free local model. Set GEMINI_API_KEY before running, and
4
+ // check Google AI Studio for whatever the current model name is if the
5
+ // default below has since been superseded.
6
+ import { OwliAgent } from '../src/index.js';
7
+
8
+ const API_KEY = process.env.GEMINI_API_KEY;
9
+ const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
10
+
11
+ if (!API_KEY) throw new Error('Set GEMINI_API_KEY before running this.');
12
+
13
+ async function askMyModel(prompt) {
14
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`, {
15
+ method: 'POST',
16
+ headers: { 'content-type': 'application/json' },
17
+ body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
18
+ });
19
+ if (!res.ok) throw new Error(`Gemini returned ${res.status}: ${await res.text()}`);
20
+ const data = await res.json();
21
+ return data.candidates[0].content.parts[0].text;
22
+ }
23
+
24
+ // Real, unbounded cost with no human deciding whether reply #47 was worth
25
+ // it if this isn't here - see the landing page's own warning. Lower than
26
+ // the local-model examples' limit on purpose, since every turn here is a
27
+ // real charge.
28
+ const MAX_TURNS_PER_CONTACT = 8;
29
+ const turnCounts = new Map();
30
+
31
+ const agent = await OwliAgent.create();
32
+ console.log(`Gemini bridge running as ${agent.npub}`);
33
+
34
+ agent.onMessage(async ({ from, text }) => {
35
+ const turns = (turnCounts.get(from) || 0) + 1;
36
+ turnCounts.set(from, turns);
37
+ if (turns > MAX_TURNS_PER_CONTACT) {
38
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
39
+ return;
40
+ }
41
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
42
+ const reply = await askMyModel(text);
43
+ await agent.send(from, reply);
44
+ });
45
+
46
+ await agent.start();
47
+ console.log('listening - Ctrl+C to stop');
@@ -0,0 +1,44 @@
1
+ // Bridge to Groq's real cloud API (https://groq.com) - fast inference,
2
+ // OpenAI-compatible chat-completions shape. A paid, metered API past its
3
+ // free tier, so the turn limit below isn't optional. Set GROQ_API_KEY
4
+ // before running.
5
+ import { OwliAgent } from '../src/index.js';
6
+
7
+ const API_KEY = process.env.GROQ_API_KEY;
8
+ const MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile';
9
+
10
+ if (!API_KEY) throw new Error('Set GROQ_API_KEY before running this.');
11
+
12
+ async function askMyModel(prompt) {
13
+ const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
14
+ method: 'POST',
15
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
16
+ body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
17
+ });
18
+ if (!res.ok) throw new Error(`Groq returned ${res.status}: ${await res.text()}`);
19
+ const data = await res.json();
20
+ return data.choices[0].message.content;
21
+ }
22
+
23
+ // Real, unbounded cost with no human deciding whether reply #47 was worth
24
+ // it if this isn't here - see the landing page's own warning.
25
+ const MAX_TURNS_PER_CONTACT = 8;
26
+ const turnCounts = new Map();
27
+
28
+ const agent = await OwliAgent.create();
29
+ console.log(`Groq bridge running as ${agent.npub}`);
30
+
31
+ agent.onMessage(async ({ from, text }) => {
32
+ const turns = (turnCounts.get(from) || 0) + 1;
33
+ turnCounts.set(from, turns);
34
+ if (turns > MAX_TURNS_PER_CONTACT) {
35
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
36
+ return;
37
+ }
38
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
39
+ const reply = await askMyModel(text);
40
+ await agent.send(from, reply);
41
+ });
42
+
43
+ await agent.start();
44
+ console.log('listening - Ctrl+C to stop');
@@ -0,0 +1,44 @@
1
+ // Bridge to a local LM Studio model (https://lmstudio.ai) - load a model
2
+ // and start its local server (Developer tab > Start Server) first, then
3
+ // run this. LM Studio speaks the same OpenAI-compatible chat-completions
4
+ // shape openai-compatible-bridge.mjs uses; this is its own file since
5
+ // it's the local-model option most people reach for first, but see that
6
+ // file if you're pointing at something else with the same API shape.
7
+ import { OwliAgent } from '../src/index.js';
8
+
9
+ const BASE_URL = process.env.LMSTUDIO_URL || 'http://localhost:1234/v1';
10
+ const MODEL = process.env.LMSTUDIO_MODEL || 'local-model'; // LM Studio ignores this if only one model is loaded
11
+
12
+ async function askMyModel(prompt) {
13
+ const res = await fetch(`${BASE_URL}/chat/completions`, {
14
+ method: 'POST',
15
+ headers: { 'content-type': 'application/json' },
16
+ body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
17
+ });
18
+ if (!res.ok) throw new Error(`LM Studio returned ${res.status}: ${await res.text()}`);
19
+ const data = await res.json();
20
+ return data.choices[0].message.content;
21
+ }
22
+
23
+ // See ollama-bridge.mjs's comment on why this exists - same reasoning,
24
+ // kept here too since this is a copy-paste starting point people adapt.
25
+ const MAX_TURNS_PER_CONTACT = 12;
26
+ const turnCounts = new Map();
27
+
28
+ const agent = await OwliAgent.create();
29
+ console.log(`LM Studio bridge running as ${agent.npub}`);
30
+
31
+ agent.onMessage(async ({ from, text }) => {
32
+ const turns = (turnCounts.get(from) || 0) + 1;
33
+ turnCounts.set(from, turns);
34
+ if (turns > MAX_TURNS_PER_CONTACT) {
35
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further`);
36
+ return;
37
+ }
38
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
39
+ const reply = await askMyModel(text);
40
+ await agent.send(from, reply);
41
+ });
42
+
43
+ await agent.start();
44
+ console.log('listening - Ctrl+C to stop');
@@ -0,0 +1,44 @@
1
+ // Bridge to Mistral AI's real cloud API (https://mistral.ai) - their own
2
+ // hosted models, OpenAI-compatible chat-completions shape. A paid,
3
+ // metered API past its free tier, so the turn limit below isn't
4
+ // optional. Set MISTRAL_API_KEY before running.
5
+ import { OwliAgent } from '../src/index.js';
6
+
7
+ const API_KEY = process.env.MISTRAL_API_KEY;
8
+ const MODEL = process.env.MISTRAL_MODEL || 'mistral-large-latest';
9
+
10
+ if (!API_KEY) throw new Error('Set MISTRAL_API_KEY before running this.');
11
+
12
+ async function askMyModel(prompt) {
13
+ const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
14
+ method: 'POST',
15
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
16
+ body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
17
+ });
18
+ if (!res.ok) throw new Error(`Mistral returned ${res.status}: ${await res.text()}`);
19
+ const data = await res.json();
20
+ return data.choices[0].message.content;
21
+ }
22
+
23
+ // Real, unbounded cost with no human deciding whether reply #47 was worth
24
+ // it if this isn't here - see the landing page's own warning.
25
+ const MAX_TURNS_PER_CONTACT = 8;
26
+ const turnCounts = new Map();
27
+
28
+ const agent = await OwliAgent.create();
29
+ console.log(`Mistral bridge running as ${agent.npub}`);
30
+
31
+ agent.onMessage(async ({ from, text }) => {
32
+ const turns = (turnCounts.get(from) || 0) + 1;
33
+ turnCounts.set(from, turns);
34
+ if (turns > MAX_TURNS_PER_CONTACT) {
35
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
36
+ return;
37
+ }
38
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
39
+ const reply = await askMyModel(text);
40
+ await agent.send(from, reply);
41
+ });
42
+
43
+ await agent.start();
44
+ console.log('listening - Ctrl+C to stop');
@@ -0,0 +1,47 @@
1
+ // Bridge to a local Ollama model (https://ollama.com) - run `ollama pull
2
+ // llama3.2` (or whatever model you like) and `ollama serve` first, then
3
+ // run this. Same Owli-facing shape as every other bridge here; only the
4
+ // askMyModel() function differs per backend.
5
+ import { OwliAgent } from '../src/index.js';
6
+
7
+ const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
8
+ const MODEL = process.env.OLLAMA_MODEL || 'llama3.2';
9
+
10
+ async function askMyModel(prompt) {
11
+ const res = await fetch(`${OLLAMA_URL}/api/generate`, {
12
+ method: 'POST',
13
+ headers: { 'content-type': 'application/json' },
14
+ body: JSON.stringify({ model: MODEL, prompt, stream: false }),
15
+ });
16
+ if (!res.ok) throw new Error(`Ollama returned ${res.status}: ${await res.text()}`);
17
+ const data = await res.json();
18
+ return data.response;
19
+ }
20
+
21
+ // Hard turn limit, per contact - see the README/landing page's own
22
+ // warning: nothing stops two model-backed agents replying to each other
23
+ // forever, and if either side is a paid API that's real unbounded cost
24
+ // with no human deciding whether reply #47 was worth it. This one's a
25
+ // local model (free to run, no per-call cost), but the same cap is kept
26
+ // here so this file is a safe template to copy into the paid backends
27
+ // too, not just this one.
28
+ const MAX_TURNS_PER_CONTACT = 12;
29
+ const turnCounts = new Map();
30
+
31
+ const agent = await OwliAgent.create();
32
+ console.log(`Ollama bridge running as ${agent.npub}`);
33
+
34
+ agent.onMessage(async ({ from, text }) => {
35
+ const turns = (turnCounts.get(from) || 0) + 1;
36
+ turnCounts.set(from, turns);
37
+ if (turns > MAX_TURNS_PER_CONTACT) {
38
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further`);
39
+ return;
40
+ }
41
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
42
+ const reply = await askMyModel(text);
43
+ await agent.send(from, reply);
44
+ });
45
+
46
+ await agent.start();
47
+ console.log('listening - Ctrl+C to stop');
@@ -0,0 +1,45 @@
1
+ // Bridge to OpenAI's real cloud API - a paid, metered API, so the turn
2
+ // limit below isn't optional here the way it might feel for a free local
3
+ // model. Set OPENAI_API_KEY before running.
4
+ import { OwliAgent } from '../src/index.js';
5
+
6
+ const API_KEY = process.env.OPENAI_API_KEY;
7
+ const MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
8
+
9
+ if (!API_KEY) throw new Error('Set OPENAI_API_KEY before running this.');
10
+
11
+ async function askMyModel(prompt) {
12
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
13
+ method: 'POST',
14
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
15
+ body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
16
+ });
17
+ if (!res.ok) throw new Error(`OpenAI returned ${res.status}: ${await res.text()}`);
18
+ const data = await res.json();
19
+ return data.choices[0].message.content;
20
+ }
21
+
22
+ // Real, unbounded cost with no human deciding whether reply #47 was worth
23
+ // it if this isn't here - see the landing page's own warning. Lower than
24
+ // the local-model examples' limit on purpose, since every turn here is a
25
+ // real charge.
26
+ const MAX_TURNS_PER_CONTACT = 8;
27
+ const turnCounts = new Map();
28
+
29
+ const agent = await OwliAgent.create();
30
+ console.log(`OpenAI bridge running as ${agent.npub}`);
31
+
32
+ agent.onMessage(async ({ from, text }) => {
33
+ const turns = (turnCounts.get(from) || 0) + 1;
34
+ turnCounts.set(from, turns);
35
+ if (turns > MAX_TURNS_PER_CONTACT) {
36
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further (this is a paid API)`);
37
+ return;
38
+ }
39
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
40
+ const reply = await askMyModel(text);
41
+ await agent.send(from, reply);
42
+ });
43
+
44
+ await agent.start();
45
+ console.log('listening - Ctrl+C to stop');
@@ -0,0 +1,56 @@
1
+ // Generic bridge for anything that speaks the OpenAI chat-completions API
2
+ // shape - which is most of the ecosystem at this point, not just OpenAI
3
+ // itself. Real services this same script covers, just by changing
4
+ // BASE_URL/API_KEY/MODEL: Groq, Together AI, OpenRouter, Mistral AI's own
5
+ // API, Perplexity, Fireworks, DeepInfra, Azure OpenAI (with its own URL
6
+ // shape), and self-hosted servers like vLLM, llama.cpp's server, and
7
+ // text-generation-webui. If your provider isn't LM Studio, Ollama,
8
+ // OpenAI, Claude, or Gemini specifically (each has its own file here),
9
+ // it's almost certainly this one.
10
+ import { OwliAgent } from '../src/index.js';
11
+
12
+ const BASE_URL = process.env.OPENAI_COMPATIBLE_BASE_URL; // e.g. https://api.groq.com/openai/v1
13
+ const API_KEY = process.env.OPENAI_COMPATIBLE_API_KEY;
14
+ const MODEL = process.env.OPENAI_COMPATIBLE_MODEL;
15
+
16
+ if (!BASE_URL || !MODEL) {
17
+ throw new Error('Set OPENAI_COMPATIBLE_BASE_URL and OPENAI_COMPATIBLE_MODEL (and OPENAI_COMPATIBLE_API_KEY if your provider needs one).');
18
+ }
19
+
20
+ async function askMyModel(prompt) {
21
+ const res = await fetch(`${BASE_URL.replace(/\/+$/, '')}/chat/completions`, {
22
+ method: 'POST',
23
+ headers: {
24
+ 'content-type': 'application/json',
25
+ ...(API_KEY ? { authorization: `Bearer ${API_KEY}` } : {}),
26
+ },
27
+ body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: prompt }] }),
28
+ });
29
+ if (!res.ok) throw new Error(`${BASE_URL} returned ${res.status}: ${await res.text()}`);
30
+ const data = await res.json();
31
+ return data.choices[0].message.content;
32
+ }
33
+
34
+ // See ollama-bridge.mjs's comment on why this exists - doubly worth
35
+ // keeping here, since a misconfigured BASE_URL/MODEL pointed at a real
36
+ // paid API is exactly the case this guards against.
37
+ const MAX_TURNS_PER_CONTACT = 12;
38
+ const turnCounts = new Map();
39
+
40
+ const agent = await OwliAgent.create();
41
+ console.log(`OpenAI-compatible bridge (${BASE_URL}) running as ${agent.npub}`);
42
+
43
+ agent.onMessage(async ({ from, text }) => {
44
+ const turns = (turnCounts.get(from) || 0) + 1;
45
+ turnCounts.set(from, turns);
46
+ if (turns > MAX_TURNS_PER_CONTACT) {
47
+ console.log(`[stopped] hit the ${MAX_TURNS_PER_CONTACT}-turn limit with ${from.slice(0, 8)}… - not replying further`);
48
+ return;
49
+ }
50
+ console.log(`[${turns}/${MAX_TURNS_PER_CONTACT}] ${from.slice(0, 8)}…: ${text}`);
51
+ const reply = await askMyModel(text);
52
+ await agent.send(from, reply);
53
+ });
54
+
55
+ await agent.start();
56
+ console.log('listening - Ctrl+C to stop');
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@owli/agent-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Headless Node.js SDK for giving an AI agent its own private, forward-secret Nostr identity - no signup, no browser. Phase 1 of the ai.owli.chat initiative.",
5
+ "keywords": [
6
+ "nostr",
7
+ "ai-agent",
8
+ "forward-secrecy",
9
+ "double-ratchet",
10
+ "e2ee",
11
+ "decentralized-identity"
12
+ ],
13
+ "type": "module",
14
+ "main": "src/index.js",
15
+ "exports": {
16
+ ".": "./src/index.js"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "examples",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "author": "owli.chat",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "test": "node --test 'test/*.test.js'"
30
+ },
31
+ "dependencies": {
32
+ "nostr-tools": "^2.24.1",
33
+ "nostr-double-ratchet": "^0.0.138",
34
+ "@scure/bip39": "^2.3.0",
35
+ "@noble/hashes": "^1.3.1",
36
+ "ws": "^8.18.0"
37
+ },
38
+ "license": "MIT"
39
+ }