A generic AI chat server framework with pluggable providers, tools, and transport.
HAL Engine extracts the core patterns of building an AI-powered chat backend into a reusable library. It handles streaming responses, tool execution loops, WebSocket connections, and session management -- you bring your own AI provider, auth, and domain tools.
- Multi-provider support: AWS Bedrock and Google Vertex AI implemented, OpenAI and Anthropic stubbed, plus a built-in Mock. Scored per method in Providers — a provider can implement one of
AIProvider's two methods without the other - Tool system: Register custom tools with a JSON Schema
inputSchema, executed in parallel within a configurable tool loop. The schema is forwarded to the model to shape the call it produces; it is not enforced before your executor runs, so validate the input you are handed - Streaming: Real-time WebSocket streaming with thinking tag parsing and entry-based protocol
- Pluggable auth: Bring your own WebSocket and HTTP authentication
- Pluggable session store: In-memory default, swap in Redis/database/etc.
- Configurable prompts: Identity, domain context, response guidelines -- all injectable
- Express + WebSocket: Full HTTP API and WebSocket server out of the box
npm install @re-cinq/hal-engineThat is the supported install path, and the one this project releases against: every published version is built by a workflow run in this repository's CI and carries an npm provenance attestation. npm audit signatures verifies it, which tells you the tarball was built by a workflow of this repository from the commit the attestation names, rather than uploaded by whoever held a token. 0.2.0 was published once with a token, because the registry cannot hold a trusted publisher for a name that has never been published; every version after it is published by the release workflow from a published GitHub release, with no credential stored anywhere, and reaches you only after a maintainer approves it.
A git specifier also resolves, and four things differ. It builds dist/ from a checkout through the prepare script rather than installing a built artefact, so your install runs this package's TypeScript compiler. It carries no provenance, because provenance is produced at publish time. It pins whatever commit your lockfile recorded, not a version, so npm outdated has nothing to compare and a fix reaches you only when you repoint it. And npm installs a dependency under its key, not the package's name.
That last one has a trap in it. An existing "hal-engine": "github:…" entry keeps resolving after the rename — the directory is still node_modules/hal-engine, so import 'hal-engine' keeps working while the package inside it declares @re-cinq/hal-engine. The rename produces no error, no warning and no failed build. Worse, following the rename and switching your imports to @re-cinq/hal-engine is what breaks: that specifier resolves to nothing until the key is renamed too. Move to the registry specifier, or rename the key and the imports together.
import {createHalEngine, ToolRegistry} from '@re-cinq/hal-engine';
const tools = new ToolRegistry();
tools.register(
{
name: 'get_weather',
description: 'Get current weather for a city',
inputSchema: {
type: 'object',
properties: {
city: {type: 'string', description: 'City name'},
},
required: ['city'],
},
promptInstructions: 'Use this tool when the user asks about weather in a specific city.',
examplePrompts: ['What is the weather in Berlin?', 'Is it raining in Tokyo?'],
},
async input => {
const city = input.city as string;
return `Weather in ${city}: 22C, partly cloudy, wind 12 km/h NW`;
}
);
const engine = createHalEngine({
provider: {type: 'mock'},
prompt: {
identity: 'You are a helpful AI assistant.',
responseGuidelines: 'Be concise and informative.',
},
tools,
auth: {
// No `http` middleware here, so the chat routes under /api/chats answer 401 rather than serving anyone.
ws: async req => {
const token = req.headers.authorization;
if (!token) return null;
return {id: 'user-1'};
},
},
transport: {
port: 8086,
basePath: '/api',
},
});
await engine.start();This is example/server.ts apart from the import specifier, and CI type-checks that file on every pull request, so it cannot drift from the API it demonstrates.
provider: {type: 'mock'} needs no credentials and no SDK — swap it for one from the Providers table when you have them. The server listens on ws://localhost:8086/api/ws, answers GET /api/health, and mounts the demo chat routes at /api/chats, which reply 401 until you configure auth.http.
AIProvider has two methods and a provider can implement one without the other, so status is scored per method. Bedrock streams but cannot produce structured output; Vertex does both. This is the only implementation-status matrix in the repository — npm renders README.md and nothing else, and a second copy would drift from it.
| Provider | type |
sendMessage |
generateStructured |
Package |
|---|---|---|---|---|
| AWS Bedrock | 'bedrock' |
Implemented | throws Bedrock structured output is not yet implemented. |
@aws-sdk/client-bedrock-runtime |
| Google Vertex AI | 'vertex' |
Implemented | Implemented | @google-cloud/vertexai |
| Mock | 'mock' |
Implemented | Implemented | (built-in) |
| OpenAI / ChatGPT | 'openai' |
throws OpenAI provider is not yet implemented. |
throws OpenAI provider is not yet implemented. |
openai |
| Anthropic / Claude | 'anthropic' |
throws Anthropic provider is not yet implemented. |
throws Anthropic provider is not yet implemented. |
@anthropic-ai/sdk |
A stub's message continues past the sentence in the table, naming the SDK to install and a provider to copy — for example OpenAI provider is not yet implemented. Install openai and implement the streaming logic. See src/providers/bedrock/ for a reference implementation.
generateStructured returns typed JSON for internal decisions — evaluation, classification, extraction — and is never user-facing. If your application does not call it, the generateStructured column does not constrain your choice.
Switching providers is config-only only within a column. Moving from Vertex to Bedrock changes one config object if you only stream, and breaks at runtime if anything calls generateStructured. There is no compile-time signal: every provider satisfies AIProvider, and a stub satisfies it by throwing.
See the providers spec for configuration and for implementing one.
The createHalEngine() function accepts a single config object:
interface HalEngineConfig {
provider: ProviderConfig; // Which AI provider to use
prompt: PromptBuilderConfig; // System prompt configuration
tools?: ToolRegistry; // Registered tools (optional)
session?: SessionStore; // Custom session store (default: in-memory)
transport?: {
port?: number; // Server port (default: 8086)
corsOrigin?: string | string[];
basePath?: string; // URL prefix (default: '/hal')
heartbeatIntervalMs?: number; // WS heartbeat (default: 30000)
};
auth: {
ws: WsAuthenticator; // WebSocket auth function
http?: HttpAuthMiddleware; // Express auth middleware
};
orchestrator?: {
maxToolRounds?: number; // Max tool loop iterations (default: 5)
contextConfig?: Partial<ContextConfig>;
};
onConnect?: (session) => void | Promise<void>; // Fire-and-forget; never awaited
onDisconnect?: (sessionId) => void | Promise<void>; // Fire-and-forget; never awaited
}Three, and only three. Everything else is a config field.
| Variable | Read at | Overridden by | Default |
|---|---|---|---|
CORS_ORIGIN |
src/transport/createApp.ts |
transport.corsOrigin |
http://localhost:3000 |
PORT |
src/transport/createServer.ts |
transport.port, or the argument to engine.start(port) |
8086 |
LOG_LEVEL |
src/shared/logger.ts |
nothing — there is no config field | info |
CORS_ORIGIN carries one origin. The value reaches cors({origin}) unsplit, so a comma-separated list is a single literal string that matches no browser origin. Pass an array to transport.corsOrigin for several.
PORT is read only when neither engine.start(port) nor transport.port supplied one. A value that is not a positive number falls through to 8086 — Number(process.env.PORT) || 8086, so PORT=0 and PORT=http both yield 8086. transport.port is resolved with ?? rather than ||, so a configured 0 means "let the OS choose a free port" and is not replaced by the default.
LOG_LEVEL is resolved once at module load, so changing process.env.LOG_LEVEL afterwards has no effect. See Logging.
No vendor credential variable appears here, because this package reads none of them. See Providers.
src/
index.ts # Public API exports
config.ts # createHalEngine() factory
types/ # Layer 1: Core types and interfaces
providers/ # Layer 2: AI provider implementations
infrastructure/ # Layer 3: Stores, builders, parsers
orchestration/ # Layer 4: Chat orchestrator, tools, entry management
transport/ # Layer 5: Express app, WebSocket server, routes
shared/ # Layer 0: Cross-cutting utilities (logger)
- Getting Started
- Architecture
- WebSocket Protocol
- Adding a Tool
- Tool Responses
- Providers
- Logging
- Contributing
- Coding Practices
Do not open a public issue for a vulnerability. Report it privately to security@re-cinq.com; we aim to acknowledge within 48 hours.
This package terminates WebSocket connections, runs registered tools against caller-supplied input, and forwards conversation state to third-party model APIs, so the reports that matter are the ones that cross a session boundary, escape the tool contract, or move credentials. Full intake, supported versions and scope are in SECURITY.md.
npm install
npm run typecheck # Type check
npm test # Run tests
npm run build # Build to dist/
npm run dev # Dev server with hot reloadApache-2.0. The full text is in LICENSE, and the same identifier is the license field of package.json.