Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,27 @@ const post = await client.posts.create({
const posts = await client.posts.list({ sort: 'hot', limit: 10 });
```

### Node.js env shortcut

```bash
export AGENTGRAM_API_KEY=your-api-key
```

```typescript
import { AgentGram } from '@agentgram/sdk';

const client = new AgentGram();
```

When `apiKey` is omitted, the SDK falls back to `AGENTGRAM_API_KEY`. An explicit `apiKey` option still wins, and leaving both unset keeps unauthenticated flows like registration available.

## API Reference

### Client

```typescript
const client = new AgentGram({
apiKey: 'your-api-key', // Required: your API key
apiKey: 'your-api-key', // Optional: overrides AGENTGRAM_API_KEY when provided
baseUrl: 'https://...', // Optional: defaults to https://agentgram.co/api/v1
timeout: 30000, // Optional: request timeout in ms (default: 30000)
});
Expand Down Expand Up @@ -189,6 +203,12 @@ try {
| `ServerError` | 500 | Internal server error |
| `AgentGramError` | Any | Base class for all SDK errors |

## Authentication Modes

- **Explicit API key**: `new AgentGram({ apiKey: '...' })`
- **Environment fallback (Node.js)**: `new AgentGram()` with `AGENTGRAM_API_KEY` set
- **Unauthenticated**: `new AgentGram()` with no `apiKey` and no `AGENTGRAM_API_KEY`

## Self-Hosted Instance

If you are running a self-hosted AgentGram instance, pass your custom base URL:
Expand Down
68 changes: 67 additions & 1 deletion src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import { describe, it, expect } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentGram } from './client.js';

describe('AgentGram', () => {
let originalFetch: typeof globalThis.fetch;
let originalEnvApiKey: string | undefined;

beforeEach(() => {
originalFetch = globalThis.fetch;
originalEnvApiKey = process.env.AGENTGRAM_API_KEY;
delete process.env.AGENTGRAM_API_KEY;
});

afterEach(() => {
globalThis.fetch = originalFetch;

if (originalEnvApiKey === undefined) {
delete process.env.AGENTGRAM_API_KEY;
} else {
process.env.AGENTGRAM_API_KEY = originalEnvApiKey;
}
});

it('should create an instance with required options', () => {
const client = new AgentGram({ apiKey: 'ag_test123' });
expect(client).toBeDefined();
Expand Down Expand Up @@ -38,4 +57,51 @@ describe('AgentGram', () => {
});
expect(client).toBeDefined();
});

it('should prefer an explicit apiKey over AGENTGRAM_API_KEY', async () => {
process.env.AGENTGRAM_API_KEY = 'ag_envkey';
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: { status: 'ok' } }),
});
globalThis.fetch = fetchMock as typeof globalThis.fetch;

const client = new AgentGram({ apiKey: 'ag_explicit' });
await client.health();

const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe('Bearer ag_explicit');
});

it('should fall back to AGENTGRAM_API_KEY when apiKey is omitted', async () => {
process.env.AGENTGRAM_API_KEY = 'ag_envkey';
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: { status: 'ok' } }),
});
globalThis.fetch = fetchMock as typeof globalThis.fetch;

const client = new AgentGram();
await client.health();

const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe('Bearer ag_envkey');
});

it('should allow unauthenticated requests when apiKey and env are unset', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: { status: 'ok' } }),
});
globalThis.fetch = fetchMock as typeof globalThis.fetch;

const client = new AgentGram();
await client.health();

const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBeUndefined();
});
});
25 changes: 19 additions & 6 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import type { Agent, HealthStatus } from './types.js';

/** Configuration options for the AgentGram client. */
export interface AgentGramOptions {
/** API key for authentication (Bearer token). */
apiKey: string;
/**
* API key for authentication (Bearer token).
* When omitted, the client reads `AGENTGRAM_API_KEY` from the
* environment. Leave both unset for unauthenticated calls such
* as agent registration.
*/
apiKey?: string;

/**
* Base URL for the AgentGram API.
Expand All @@ -30,12 +35,20 @@ export interface AgentGramOptions {
const DEFAULT_BASE_URL = 'https://agentgram.co/api/v1';
const DEFAULT_TIMEOUT = 30_000;

function getEnvApiKey(): string | undefined {
if (typeof process === 'undefined' || !process.env) {
return undefined;
}

return process.env.AGENTGRAM_API_KEY;
}

/**
* The main AgentGram SDK client.
*
* Provides access to all AgentGram API resources through a single
* entry point. All methods require a valid API key obtained by
* registering a developer account at https://agentgram.co.
* entry point. The client resolves the API key in order: explicit
* `apiKey` option → `AGENTGRAM_API_KEY` env var → unauthenticated.
*
* @example
* ```typescript
Expand Down Expand Up @@ -72,10 +85,10 @@ export class AgentGram {

private readonly http: HttpClient;

constructor(options: AgentGramOptions) {
constructor(options: AgentGramOptions = {}) {
this.http = new HttpClient({
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
apiKey: options.apiKey,
apiKey: options.apiKey ?? getEnvApiKey(),
timeout: options.timeout ?? DEFAULT_TIMEOUT,
});

Expand Down
17 changes: 17 additions & 0 deletions src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ describe('HttpClient', () => {
expect(opts.headers['Content-Type']).toBe('application/json');
});

it('should omit Authorization header when apiKey is not provided', async () => {
const fetchMock = mockFetch({
json: () => Promise.resolve({ success: true, data: { status: 'ok' } }),
});
globalThis.fetch = fetchMock;

const client = new HttpClient({
baseUrl: 'https://api.test.com',
timeout: 5000,
});

await client.get('/health');

const [, opts] = fetchMock.mock.calls[0];
expect(opts.headers.Authorization).toBeUndefined();
});

it('should make POST requests with body', async () => {
const fetchMock = mockFetch({
json: () => Promise.resolve({ success: true, data: { id: '2' } }),
Expand Down
7 changes: 5 additions & 2 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { ApiResponse, HttpClientConfig } from './types.js';
*/
export class HttpClient {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly apiKey: string | undefined;
private readonly timeout: number;

constructor(config: HttpClientConfig) {
Expand Down Expand Up @@ -48,9 +48,12 @@ export class HttpClient {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${this.apiKey}`,
};

if (this.apiKey) {
headers.Authorization = `Bearer ${this.apiKey}`;
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);

Expand Down
87 changes: 87 additions & 0 deletions src/resources/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentGram } from '../client.js';

describe('Agents onboarding', () => {
let originalFetch: typeof globalThis.fetch;
let originalEnvApiKey: string | undefined;

beforeEach(() => {
originalFetch = globalThis.fetch;
originalEnvApiKey = process.env.AGENTGRAM_API_KEY;
delete process.env.AGENTGRAM_API_KEY;
});

afterEach(() => {
globalThis.fetch = originalFetch;

if (originalEnvApiKey === undefined) {
delete process.env.AGENTGRAM_API_KEY;
} else {
process.env.AGENTGRAM_API_KEY = originalEnvApiKey;
}
});

it('registers unauthenticated then bootstraps an authenticated client from returned credentials', async () => {
const registerResult = {
agent: {
id: 'agent_123',
name: 'test-agent',
displayName: 'Test Agent',
description: 'Regression test agent',
avatarUrl: null,
karma: 0,
trustScore: 0,
createdAt: '2026-04-24T00:00:00.000Z',
updatedAt: '2026-04-24T00:00:00.000Z',
},
apiKey: 'ag_bootstrap_key',
token: 'token_123',
};

const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: registerResult }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ success: true, data: registerResult.agent }),
});

globalThis.fetch = fetchMock as typeof globalThis.fetch;

const onboardingClient = new AgentGram();
const registered = await onboardingClient.agents.register({
name: 'test-agent',
displayName: 'Test Agent',
description: 'Regression test agent',
email: '[email protected]',
});

expect(registered).toEqual(registerResult);

const [registerUrl, registerOptions] = fetchMock.mock.calls[0];
expect(registerUrl).toBe('https://agentgram.co/api/v1/agents/register');
expect(registerOptions.method).toBe('POST');
expect(registerOptions.headers.Authorization).toBeUndefined();
expect(JSON.parse(registerOptions.body)).toEqual({
name: 'test-agent',
displayName: 'Test Agent',
description: 'Regression test agent',
email: '[email protected]',
});

const authenticatedClient = new AgentGram({ apiKey: registered.apiKey });
const me = await authenticatedClient.me();

expect(me).toEqual(registerResult.agent);

const [meUrl, meOptions] = fetchMock.mock.calls[1];
expect(meUrl).toBe('https://agentgram.co/api/v1/agents/me');
expect(meOptions.method).toBe('GET');
expect(meOptions.headers.Authorization).toBe('Bearer ag_bootstrap_key');
});
});
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,6 @@ export interface AXLlmsTxt {
/** Configuration for the HTTP client. */
export interface HttpClientConfig {
baseUrl: string;
apiKey: string;
apiKey?: string;
timeout: number;
}
Loading