@devoven/assistant
v0.0.1
Published
Assistant module for NestJS — hexagonal architecture
Downloads
134
Readme
@devoven/assistant
AI assistant module for NestJS. Streaming chat with tool calling support.
Installation
npm install @devoven/assistant
# or
pnpm add @devoven/assistantPeer Dependencies
Standard NestJS peer dependencies (@nestjs/common, @nestjs/core, rxjs, reflect-metadata, class-validator, class-transformer) must be present in your project. Any NestJS application already has these.
The default provider is GeminiAssistantService, which requires @google/genai. Install it if you plan to use the built-in Gemini backend:
npm install @google/genai
# or
pnpm add @google/genai@google/genai is an optional peer dependency — you can skip it if you bring your own AssistantProviderPort implementation.
Quick Start
import { AssistantModule } from '@devoven/assistant';
@Module({
imports: [
AssistantModule.register({
assistantApiKey: process.env.GEMINI_API_KEY,
assistantModel: 'gemini-2.0-flash',
}),
],
})
export class AppModule {}This registers the module with the built-in Gemini provider, an in-memory conversation store, and the ChatController mounted at /chat.
Module Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| assistantApiKey | string | — | Required. API key for the underlying AI provider |
| assistantModel | string | — | Required. Model identifier (e.g. "gemini-2.0-flash") |
| assistantMaxTokens | number | 4096 | Maximum output tokens per generation |
| systemPrompt | string | null | System instruction prepended to every conversation |
| maxToolTurns | number | 10 | Maximum tool-call cycles per request before throwing |
| provider | Class or instance of AssistantProviderPort | GeminiAssistantService | Swap in a custom AI backend |
| conversationRepository | Class or instance of ConversationRepositoryPort | InMemoryConversationRepository | Persistence adapter for conversations |
| toolProviders | (Type<IToolProvider> \| IToolProvider)[] | [] | Tool providers to register with the tool context |
| controllers | boolean | true | Set to false to omit ChatController and use the ports directly |
| global | boolean | false | Register the module globally |
Async Registration
Use registerAsync when options come from a config service or need to be resolved asynchronously:
import { AssistantModule } from '@devoven/assistant';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
AssistantModule.registerAsync({
useFactory: (config: ConfigService) => ({
assistantApiKey: config.get('GEMINI_API_KEY'),
assistantModel: config.get('GEMINI_MODEL'),
systemPrompt: config.get('ASSISTANT_SYSTEM_PROMPT'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}When using registerAsync, pass provider and conversationRepository as instances (not classes). If you need a class provider, declare it in your own module and resolve it with useFactory.
REST API
The ChatController is mounted at /chat. All endpoints use a conversationId path parameter to identify a conversation session.
Open an SSE stream
GET /chat/:conversationId/streamOpens a Server-Sent Events stream for the given conversation. Subscribe to this before sending any messages. If no stream exists yet for the ID, one is created automatically.
Response: SSE stream. Each event carries a data field with a JSON payload:
| Event type | Payload shape | Description |
|------------|---------------|-------------|
| text_chunk | { type: "text_chunk", content: string } | Incremental text from the model |
| tool_call | { type: "tool_call", name: string, args: object } | A tool the model is invoking |
| tool_result | { type: "tool_result", name: string, result: unknown } | The result returned to the model |
| done | { type: "done", stopReason: string } | Stream completed |
| error | { type: "error", message: string } | An error occurred during generation |
Send a message
POST /chat/:conversationIdSends a user message to the conversation. The response streams back through the SSE connection opened above. You must open the stream first — this endpoint returns 404 if there is no active stream for the conversation.
Request body:
{
"message": "What is the capital of France?"
}Response: 204 No Content
Errors: 404 Not Found if no active SSE stream exists for conversationId.
Typical usage sequence
GET /chat/my-conversation-id/stream— open the SSE stream and listen for eventsPOST /chat/my-conversation-id— send the first message- Receive
text_chunkevents as the model responds, followed bydone - Repeat from step 2 for follow-up messages
Architecture
The module follows hexagonal architecture with three port categories.
Port / Token Mapping
| Token | Interface | Direction | Description |
|-------|-----------|-----------|-------------|
| ASSISTANT_TOKENS.StreamChatPort | StreamChatPort | Driving | Entry point — executes a streaming chat turn |
| ASSISTANT_TOKENS.AssistantProviderPort | AssistantProviderPort | Driven | Calls the underlying AI API |
| ASSISTANT_TOKENS.ConversationRepositoryPort | ConversationRepositoryPort | Driven | Loads and persists conversation history |
| ASSISTANT_TOKENS.IToolContext | IToolContext | Internal | Registry of all available tools at runtime |
All four tokens are exported from the module and can be injected into other services.
Tool system
Tools are provided by implementing IToolProvider:
import { IToolProvider, IExecutableTool } from '@devoven/assistant';
import { Injectable } from '@nestjs/common';
@Injectable()
export class WeatherToolProvider implements IToolProvider {
getListTools(): IExecutableTool[] {
return [
{
name: 'get_weather',
description: 'Get current weather for a city',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name' },
},
required: ['city'],
},
async handler(args: unknown) {
const { city } = args as { city: string };
// fetch weather...
return { temperature: 22, condition: 'sunny' };
},
},
];
}
}Register providers when importing the module:
AssistantModule.register({
assistantApiKey: process.env.GEMINI_API_KEY,
assistantModel: 'gemini-2.0-flash',
toolProviders: [WeatherToolProvider],
})Pass class references for providers that need their own injected dependencies. Pass instances directly if they have no dependencies.
Domain model
Conversation — aggregate root for a chat session.
| Method | Description |
|--------|-------------|
| Conversation.create() | Create a new empty conversation with a generated UUID |
| Conversation.reconstitute(data) | Rebuild from persistence |
| addMessage(msg) | Append an AssistantMessage |
| getMessages() | Return all messages as an array |
| lastMessage | Last message in the conversation, or null |
AssistantMessage — immutable value object representing a single message.
| Method | Description |
|--------|-------------|
| AssistantMessage.create({ role, content }) | Create with role "user" or "assistant" |
Custom Adapters
Custom AI provider
Implement AssistantProviderPort to connect a different model backend:
import { AssistantProviderPort, AssistantMessage, AssistantResponse } from '@devoven/assistant';
import { Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { MessageEvent } from '@nestjs/common';
import { IExecutableTool } from '@devoven/assistant';
@Injectable()
export class OpenAIAssistantService implements AssistantProviderPort {
async chat(
messages: AssistantMessage[],
tools: Record<string, IExecutableTool>,
): Promise<AssistantResponse> {
// call OpenAI...
}
streamChat(
messages: AssistantMessage[],
tools: Record<string, IExecutableTool>,
): Observable<MessageEvent> {
// stream from OpenAI...
}
}Pass the instance via the provider option:
AssistantModule.register({
assistantApiKey: 'unused',
assistantModel: 'gpt-4o',
provider: new OpenAIAssistantService(openAiClient),
})Custom conversation repository
Implement ConversationRepositoryPort to persist conversations in a database:
import { ConversationRepositoryPort, Conversation } from '@devoven/assistant';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PrismaConversationRepository implements ConversationRepositoryPort {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<Conversation | null> { /* ... */ }
async save(conversation: Conversation): Promise<void> { /* ... */ }
}Pass the instance via conversationRepository:
AssistantModule.register({
assistantApiKey: process.env.GEMINI_API_KEY,
assistantModel: 'gemini-2.0-flash',
conversationRepository: new PrismaConversationRepository(prisma),
})Using ports directly (without the HTTP controller)
Set controllers: false and inject the exported ports into your own services:
import { Inject, Injectable } from '@nestjs/common';
import { ASSISTANT_TOKENS, StreamChatPort } from '@devoven/assistant';
@Injectable()
export class MyService {
constructor(
@Inject(ASSISTANT_TOKENS.StreamChatPort)
private readonly streamChat: StreamChatPort,
) {}
}