byourside 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 +21 -0
- package/README.md +125 -0
- package/package.json +19 -0
- package/src/client.js +65 -0
- package/src/errors.js +32 -0
- package/src/index.js +3 -0
- package/src/webhooks.js +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 By Your Side
|
|
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,125 @@
|
|
|
1
|
+
# By Your Side Node SDK
|
|
2
|
+
|
|
3
|
+
Zero-dependency Node 18+ SDK for the By Your Side Agent Outbound-Call API. Place objective-driven AI calls, poll for results, and verify webhooks.
|
|
4
|
+
|
|
5
|
+
## Quickstart
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { Client, ByoursideError, verifyWebhook } from './src/index.js';
|
|
9
|
+
|
|
10
|
+
const client = new Client({ apiKey: 'bys_ak_...' });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Place a call and wait for the result
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
try {
|
|
17
|
+
// Place the call
|
|
18
|
+
const { callId } = await client.placeCall({
|
|
19
|
+
to: '+14155550123',
|
|
20
|
+
objective: 'Confirm the appointment for tomorrow at 2 PM and ask if they need to reschedule.',
|
|
21
|
+
fields: [
|
|
22
|
+
{ name: 'confirmed', type: 'boolean' },
|
|
23
|
+
{ name: 'new_time', type: 'string' },
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Poll until the call reaches a terminal status (default timeout: 3 minutes)
|
|
28
|
+
const call = await client.waitForCall(callId, { timeoutMs: 120_000, intervalMs: 5_000 });
|
|
29
|
+
|
|
30
|
+
console.log('Status:', call.status); // completed | no_answer | voicemail | declined | failed
|
|
31
|
+
console.log('Extracted fields:', call.extracted);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err instanceof ByoursideError) {
|
|
34
|
+
console.error(`[${err.code}] ${err.message}`);
|
|
35
|
+
} else {
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## List recent calls
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const calls = await client.listCalls({ limit: 20 });
|
|
45
|
+
calls.forEach((c) => console.log(c.callId, c.status));
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Verify a webhook (Express example)
|
|
49
|
+
|
|
50
|
+
Receive real-time call events by setting a `webhookUrl` when placing a call. The header `X-BYS-Signature` carries the signature.
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
import express from 'express';
|
|
54
|
+
import { verifyWebhook } from './src/index.js';
|
|
55
|
+
|
|
56
|
+
const app = express();
|
|
57
|
+
|
|
58
|
+
// IMPORTANT: parse the body as raw bytes so the signature can be verified.
|
|
59
|
+
app.post('/webhooks/bys', express.raw({ type: 'application/json' }), (req, res) => {
|
|
60
|
+
const sig = req.headers['x-bys-signature'];
|
|
61
|
+
const rawBody = req.body.toString('utf8');
|
|
62
|
+
|
|
63
|
+
if (!verifyWebhook(sig, rawBody, process.env.BYS_WEBHOOK_SECRET)) {
|
|
64
|
+
return res.status(400).send('Bad signature');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const event = JSON.parse(rawBody);
|
|
68
|
+
console.log('Webhook event:', event.callId, event.status);
|
|
69
|
+
res.status(200).send('OK');
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Call status values
|
|
74
|
+
|
|
75
|
+
| Status | Meaning |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `queued` | Accepted, not yet dialing |
|
|
78
|
+
| `in_progress` | Call is active |
|
|
79
|
+
| `completed` | Call finished; summary + fields available |
|
|
80
|
+
| `no_answer` | Rang but nobody picked up |
|
|
81
|
+
| `voicemail` | Reached voicemail |
|
|
82
|
+
| `declined` | Call rejected by the recipient |
|
|
83
|
+
| `failed` | Carrier or trunk error |
|
|
84
|
+
|
|
85
|
+
Terminal statuses (waitForCall stops here): `completed`, `no_answer`, `voicemail`, `declined`, `failed`.
|
|
86
|
+
|
|
87
|
+
## Error codes
|
|
88
|
+
|
|
89
|
+
`ByoursideError` instances carry `.code` (a token string) and `.status` (HTTP status, 0 for network errors).
|
|
90
|
+
|
|
91
|
+
| Code | Meaning |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `destination_blocked` | That destination is not allowed (premium, IRSF, or unsupported country). |
|
|
94
|
+
| `invalid_number` | The destination number is invalid (use full E.164, e.g. +14155550123). |
|
|
95
|
+
| `to_required` | A destination number (to) is required. |
|
|
96
|
+
| `objective_required` | An objective for the call is required. |
|
|
97
|
+
| `caller_id_not_owned` | That caller ID is not a number on your account. |
|
|
98
|
+
| `rate_limited` | Rate limit reached. Try again shortly. |
|
|
99
|
+
| `over_minute_cap` | Outbound usage limit reached for now. |
|
|
100
|
+
| `unauthorized` | Invalid or missing API key. |
|
|
101
|
+
| `not_found` | No call found with that id (or it does not belong to your account). |
|
|
102
|
+
| `placement_failed` | The call could not be placed (carrier/trunk issue). Try again shortly. |
|
|
103
|
+
| `store_error` | Temporary service error. Please retry shortly. |
|
|
104
|
+
| `timeout` | waitForCall hit the timeout before reaching a terminal status. |
|
|
105
|
+
| `network_error` | Could not reach the API (network failure). |
|
|
106
|
+
|
|
107
|
+
## Constructor options
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
new Client({
|
|
111
|
+
apiKey, // required
|
|
112
|
+
baseUrl, // default: 'https://api.byourside.ai'
|
|
113
|
+
fetchImpl, // override global fetch (useful for tests)
|
|
114
|
+
sleep, // override the poll sleep (useful for tests)
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
node --check src/*.js # syntax check
|
|
122
|
+
node --test # run all tests (run from sdks/node/)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
No dependencies are required. Node 18+ is needed for built-in `fetch`.
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "byourside",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "By Your Side SDK: give an AI a phone (outbound objective-driven calls).",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "By Your Side",
|
|
8
|
+
"homepage": "https://byourside.ai/docs/agent-api",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/allexp1/voip-agent.git",
|
|
12
|
+
"directory": "sdks/node"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["voice", "ai", "phone", "outbound", "agent", "sdk", "voip", "calls"],
|
|
15
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
16
|
+
"main": "src/index.js",
|
|
17
|
+
"scripts": { "test": "node --test" },
|
|
18
|
+
"engines": { "node": ">=18" }
|
|
19
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// By Your Side SDK client. Zero deps (built-in fetch). Throws ByoursideError on any
|
|
2
|
+
// non-2xx or network failure. fetchImpl + sleep injectable for tests.
|
|
3
|
+
import { mapError, ByoursideError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
const TERMINAL = new Set(['completed', 'no_answer', 'voicemail', 'declined', 'failed']);
|
|
6
|
+
|
|
7
|
+
export class Client {
|
|
8
|
+
constructor({ apiKey, baseUrl = 'https://api.byourside.ai', fetchImpl = globalThis.fetch, sleep } = {}) {
|
|
9
|
+
if (!apiKey) throw new ByoursideError('apiKey is required', 0, 'no_api_key');
|
|
10
|
+
this._apiKey = apiKey;
|
|
11
|
+
this._base = String(baseUrl).replace(/\/+$/, '');
|
|
12
|
+
this._fetch = fetchImpl;
|
|
13
|
+
this._sleep = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async _request(method, path, body) {
|
|
17
|
+
const opts = { method, headers: { Authorization: `Bearer ${this._apiKey}` } };
|
|
18
|
+
if (body !== undefined && method !== 'GET') {
|
|
19
|
+
opts.headers['content-type'] = 'application/json';
|
|
20
|
+
opts.body = JSON.stringify(body);
|
|
21
|
+
}
|
|
22
|
+
let res;
|
|
23
|
+
try { res = await this._fetch(`${this._base}${path}`, opts); }
|
|
24
|
+
catch (e) { throw new ByoursideError(mapError(0, null), 0, 'network_error'); }
|
|
25
|
+
let data = null;
|
|
26
|
+
try { data = await res.json(); } catch { data = null; }
|
|
27
|
+
if (!res.ok) throw new ByoursideError(mapError(res.status, data), res.status, (data && data.error) || 'error');
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
placeCall({ to, objective, context, fields, webhookUrl, callerId } = {}) {
|
|
32
|
+
const body = { to, objective };
|
|
33
|
+
if (context !== undefined) body.context = context;
|
|
34
|
+
if (fields !== undefined) body.fields = fields;
|
|
35
|
+
if (webhookUrl !== undefined) body.webhookUrl = webhookUrl;
|
|
36
|
+
if (callerId !== undefined) body.callerId = callerId;
|
|
37
|
+
return this._request('POST', '/v1/agent/calls', body);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getCall(callId) {
|
|
41
|
+
return this._request('GET', `/v1/agent/calls/${encodeURIComponent(callId)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async listCalls({ limit = 20 } = {}) {
|
|
45
|
+
const n = Math.min(Math.max(1, Number(limit) || 20), 100);
|
|
46
|
+
const data = await this._request('GET', `/v1/agent/calls?limit=${n}`);
|
|
47
|
+
return (data && data.calls) || [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Poll getCall until status is terminal or timeoutMs elapses. Throws ByoursideError
|
|
51
|
+
// code 'timeout' if still non-terminal. timeoutMs/intervalMs in milliseconds.
|
|
52
|
+
async waitForCall(callId, { timeoutMs = 180000, intervalMs = 3000 } = {}) {
|
|
53
|
+
const deadline = Date.now() + timeoutMs;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const call = await this.getCall(callId);
|
|
56
|
+
if (call && TERMINAL.has(call.status)) return call;
|
|
57
|
+
if (Date.now() + intervalMs > deadline) {
|
|
58
|
+
throw new ByoursideError(`Call ${callId} did not finish within ${Math.round(timeoutMs / 1000)}s`, 0, 'timeout');
|
|
59
|
+
}
|
|
60
|
+
await this._sleep(intervalMs);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { TERMINAL };
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Typed error + token->message mapping for the By Your Side SDK. Mirrors the table
|
|
2
|
+
// used by the MCP server. Never includes the API key.
|
|
3
|
+
const TOKEN_MESSAGES = {
|
|
4
|
+
destination_blocked: 'That destination is not allowed (premium, IRSF, or unsupported country).',
|
|
5
|
+
invalid_number: 'The destination number is invalid (use full E.164, e.g. +14155550123).',
|
|
6
|
+
to_required: 'A destination number (to) is required.',
|
|
7
|
+
objective_required: 'An objective for the call is required.',
|
|
8
|
+
caller_id_not_owned: 'That caller ID is not a number on your account.',
|
|
9
|
+
rate_limited: 'Rate limit reached. Try again shortly.',
|
|
10
|
+
over_minute_cap: 'Outbound usage limit reached for now.',
|
|
11
|
+
unauthorized: 'Invalid or missing API key.',
|
|
12
|
+
not_found: 'No call found with that id (or it does not belong to your account).',
|
|
13
|
+
placement_failed: 'The call could not be placed (carrier/trunk issue). Try again shortly.',
|
|
14
|
+
store_error: 'Temporary service error. Please retry shortly.',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function mapError(status, body) {
|
|
18
|
+
const token = body && typeof body === 'object' ? String(body.error || '') : '';
|
|
19
|
+
if (token && TOKEN_MESSAGES[token]) return TOKEN_MESSAGES[token];
|
|
20
|
+
if (!status || status >= 500) return 'Temporary service error. Please retry shortly.';
|
|
21
|
+
if (token) return `Request failed (${token}).`;
|
|
22
|
+
return `Request failed with status ${status}.`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ByoursideError extends Error {
|
|
26
|
+
constructor(message, status, code) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ByoursideError';
|
|
29
|
+
this.status = status;
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.js
ADDED
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Verify a By Your Side webhook signature header. Never throws; returns boolean.
|
|
2
|
+
// Header: "t=<unix_seconds>,v1=<hex>"; sig = HMAC-SHA256(secret, `${t}.${rawBody}`).
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export function verifyWebhook(signatureHeader, rawBody, secret, { toleranceSec = 300 } = {}) {
|
|
6
|
+
try {
|
|
7
|
+
if (!signatureHeader || !secret) return false;
|
|
8
|
+
const parts = Object.fromEntries(String(signatureHeader).split(',').map((kv) => {
|
|
9
|
+
const i = kv.indexOf('='); return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
|
|
10
|
+
}));
|
|
11
|
+
const t = Number(parts.t); const v1 = parts.v1;
|
|
12
|
+
if (!Number.isFinite(t) || !v1) return false;
|
|
13
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
|
|
14
|
+
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
|
|
15
|
+
const a = Buffer.from(expected); const b = Buffer.from(String(v1));
|
|
16
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
}
|