@kpab/flue-line
v0.1.0
Published
Verified LINE Messaging API webhook ingress and reply/push tools for Flue applications.
Maintainers
Readme
@kpab/flue-line
Verified LINE Messaging API webhook ingress, plus reply/push send tools, for Flue applications.
This is an unofficial, community-maintained channel. As of this writing
there is no first-party @flue/line package in the
Flue ecosystem — @kpab/flue-line
fills that gap, following the same design as first-party channels like
@flue/github
and @flue/slack.
Quickstart
npm install @kpab/flue-line@flue/runtime is a peer dependency — install it if you haven't already
(any Flue app already has it):
npm install @flue/runtimeOverview
createLineChannel() verifies every inbound delivery's X-Line-Signature
against the exact request bytes before your webhook callback ever runs,
and narrows each event by its type:
import { createLineChannel } from '@kpab/flue-line';
export const channel = createLineChannel({
channelSecret: process.env.LINE_CHANNEL_SECRET!,
// Path: /channels/line/webhook
async webhook({ event, destination }) {
// `event.type` discriminates the rest of `event`'s shape.
if (event.type === 'message' && event.message.type === 'text') {
console.log(event.message.text);
}
},
});LINE batches multiple events into a single HTTP delivery — unlike GitHub's
one-event-per-delivery model — so webhook() is called once per event in
that delivery, not once per request. Returning a Response from any call
stops processing the remaining events in that delivery and sends it
directly; returning nothing (for every event) yields an empty 200 once
they're all processed. The package is stateless: LINE has no delivery id to
deduplicate on, so keep your handler idempotent.
Supported event types (event.type): message (narrows message.type to
text | image | video | audio | file | location | sticker),
unsend, follow, unfollow, join, leave, memberJoined,
memberLeft, postback, videoPlayComplete, beacon, accountLink, and
membership — matching the
official webhook event schema.
LINE Things (IoT device link/unlink/scenario) and a handful of other niche
event types are out of scope.
Configure
Create a Messaging API channel in the LINE Developers console and set:
LINE_CHANNEL_SECRET=... # Basic settings tab — verifies inbound webhooks
LINE_CHANNEL_ACCESS_TOKEN=... # Messaging API tab — Bearer token for reply/pushTurn off the LINE Official Account's own auto-reply and greeting messages in the LINE Official Account Manager so only your agent replies.
Channel module
Place this export in src/channels/line.ts. Flue discovers it and serves
POST /channels/line/webhook relative to the flue() mount:
import { dispatch } from '@flue/runtime';
import { createLineChannel } from '@kpab/flue-line';
import assistant from '../agents/assistant.ts';
export const channel = createLineChannel({
channelSecret: process.env.LINE_CHANNEL_SECRET!,
async webhook({ event }) {
if (event.type !== 'message' || event.message.type !== 'text') return;
if (event.source?.type !== 'user') return;
await dispatch(assistant, {
// One session per LINE user.
id: channel.conversationKey({ type: 'user', userId: event.source.userId }),
input: {
type: 'line.message',
eventId: event.webhookEventId,
text: event.message.text,
},
});
},
});channel.conversationKey() serializes a canonical, namespaced identifier
for a 1-on-1 user, group chat, or multi-person room — it is not an
authorization capability. channel.parseConversationKey() parses only keys
produced by conversationKey(), and round-trips them back to a
{ type: 'user' | 'group' | 'room', ... } ref you can pass straight to the
push tool's to.
Bind the tool
Outbound send calls (reply/push) live in a separate module,
@kpab/flue-line/tools, so the channel itself never depends on them — the
channel's job is verified ingress, and yours is deciding when and how to
answer.
defineAgent()'s initializer runs once per session and only receives
{ id, env } (@flue/runtime's AgentInitializerContext) — not the
per-message input passed to dispatch() — so a session's tools are wired
once from context.id, not from a single event. Bind the push tool
there, parsing the stable LINE destination back out of the conversation key:
import { defineAgent } from '@flue/runtime';
import { createPushMessageTool } from '@kpab/flue-line/tools';
import { channel } from '../channels/line.ts';
export default defineAgent((context) => {
const ref = channel.parseConversationKey(context.id);
const to = ref.type === 'user' ? ref.userId : ref.type === 'group' ? ref.groupId : ref.roomId;
return {
model: 'anthropic/claude-sonnet-4-6',
instructions: 'Reply to LINE messages helpfully and concisely.',
tools: [createPushMessageTool({ channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!, to })],
};
});A LINE reply token is single-use and expires shortly after its webhook
fires, so it doesn't fit a tool wired once for a session's whole lifetime.
Use createReplyMessageTool({ channelAccessToken, replyToken }) for an
immediate acknowledgement instead, called directly (not exposed to the
model) from inside webhook(), before dispatch():
await createReplyMessageTool({
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN!,
replyToken: event.replyToken,
}).run({ input: { text: 'Got it, thinking…' }, signal: undefined });In both cases the model only ever chooses the message text — the
replyToken and push to destination are bound by trusted code, never
exposed as a model-selectable input.
See examples/minimal-agent for a complete,
runnable app wiring the channel and both tools together.
Testing
npm test # Node.js (vitest)
npm run test:workerd # Cloudflare Workers (Miniflare, nodejs_compat)Signature verification uses Web Crypto SubtleCrypto only, so the same
implementation runs unmodified on both runtimes.
