@keenable/convex
v0.1.0
Published
Keenable web search and page fetch as a Convex component. Keyless by default.
Readme
@keenable/convex
Keenable web search and page fetch as a Convex component.
Keyless by default. Install it, use it. An API key is optional and only lifts the rate limit, so a Convex app can search the web without holding a credential at all.
Install
npm install @keenable/convexAdd the component to convex/convex.config.ts:
import { defineApp } from "convex/server";
import keenable from "@keenable/convex/convex.config";
const app = defineApp();
app.use(keenable);
export default app;That is the whole setup. To lift the rate limit, declare the key on the app and bind it:
import { defineApp } from "convex/server";
import { v } from "convex/values";
import keenable from "@keenable/convex/convex.config";
const app = defineApp({ env: { KEENABLE_API_KEY: v.string() } });
app.use(keenable, { env: { KEENABLE_API_KEY: app.env.KEENABLE_API_KEY } });
export default app;npx convex env set KEENABLE_API_KEY your-keyKeys come from keenable.ai/console.
Search the web
Call the component from an application-owned action. Keeping the wrapper in the app gives you a place for authentication, authorization and rate limiting.
import { KeenableClient } from "@keenable/convex";
import { v } from "convex/values";
import { action } from "./_generated/server";
import { components } from "./_generated/api";
const keenable = new KeenableClient(components.keenable);
export const searchWeb = action({
args: { query: v.string() },
handler: async (ctx, args) => {
return await keenable.search(ctx, { query: args.query, maxResults: 5 });
},
});Each result carries title, url and snippet. snippet is the page text,
not a search-engine blurb, which is why it has a character budget:
await keenable.search(ctx, {
query: "papers on wafer-scale inference",
site: "arxiv.org",
publishedAfter: "2026-01-01",
snippetMaxLength: 2_000,
maxResults: 5,
});Ask for the ideal page in natural language ("blog post comparing React and Vue performance") rather than typing keywords; the index is semantic.
Read one page
export const readPage = action({
args: { url: v.string() },
handler: async (ctx, args) => {
return await keenable.fetch(ctx, { url: args.url, contentMaxLength: 20_000 });
},
});Returns the page's main content as markdown with boilerplate stripped, plus
truncated so you know whether the budget cut it.
Feed a model
toContext renders results as numbered sources with their URLs, so a model can
cite what it used. Sources are dropped whole once the budget is spent rather
than cut mid-page.
import { KeenableClient, toContext } from "@keenable/convex";
const { results } = await keenable.search(ctx, { query: args.question });
const sources = toContext(results, { maxChars: 8_000 });Request flow
App client
-> application Convex action
-> KeenableClient.search / .fetch
-> ctx.runAction(components.keenable.lib.search / .fetchPage)
-> POST https://api.keenable.ai/v1/search/public (or /v1/search with a key)
GET https://api.keenable.ai/v1/fetch/public (or /v1/fetch with a key)The component is stateless and owns no database tables.
API
search(ctx, args)—query, plus optionalsite,publishedAfter,publishedBefore,acquiredAfter,acquiredBefore,snippetMaxLength(default 1000) andmaxResults.fetch(ctx, args)—url, plus optionalcontentMaxLength(default 10000).toContext(results, { maxChars })— a citable prompt block.
Development
npm install
npm test
npm run check
npm run build
node e2e/live.mjs # live, keyless, hits the real APInpm run build:codegen regenerates src/component/_generated and needs a
configured Convex development deployment.
License
MIT
