<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Hikmah Labs Engineering]]></title><description><![CDATA[Hikmah Labs Engineering]]></description><link>https://hikmahlabs.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Hikmah Labs Engineering</title><link>https://hikmahlabs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 15 Sep 2026 19:47:44 GMT</lastBuildDate><atom:link href="https://hikmahlabs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Five Layers of Protection for Payments and AI Requests]]></title><description><![CDATA[Ask an assistant to "build payment processing" and you get code that works. Click the button, pay, get access. Tests pass, the demo looks convincing to the client. Then someone opens devtools and pays]]></description><link>https://hikmahlabs.hashnode.dev/five-layers-of-protection-for-payments-and-ai-requests</link><guid isPermaLink="true">https://hikmahlabs.hashnode.dev/five-layers-of-protection-for-payments-and-ai-requests</guid><category><![CDATA[Security]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[payments]]></category><dc:creator><![CDATA[kh.vibecoding]]></dc:creator><pubDate>Fri, 11 Sep 2026 07:21:13 GMT</pubDate><content:encoded><![CDATA[<p>Ask an assistant to "build payment processing" and you get code that works. Click the button, pay, get access. Tests pass, the demo looks convincing to the client. Then someone opens devtools and pays a dollar instead of a hundred. Here is what an AI assistant leaves broken in payment handling by default, on a real client project, and what we build around payments and AI requests so it does not happen.</p>
<h2>Hole one: the price comes from the client</h2>
<p>Here is what gets generated by default if you simply ask for "payment processing":</p>
<pre><code class="language-js">// do not do this
app.post('/checkout', async (req, res) =&gt; {
  const { productId, amount } = req.body;        // amount came from the browser
  const session = await psp.createSession({ amount, currency: 'usd' });
  res.json({ url: session.url });
});
</code></pre>
<p>It looks logical enough: the frontend knows the price, so it sends it. But the frontend runs on the buyer's machine, and it takes one line in the browser console to edit. <code>amount: 10000</code> becomes <code>amount: 100</code>, the payment provider happily charges a dollar, and the product ships.</p>
<p>The fix: the client sends only the id of what it is buying, and the server looks up the price itself.</p>
<pre><code class="language-js">app.post('/checkout', requireAuth, async (req, res) =&gt; {
  const product = await products.get(req.body.product_id);
  if (!product) return res.sendStatus(404);
  // record the order before calling the PSP: we reconcile the webhook against it later
  const order = await orders.create({
    user_id:      req.user.id,
    product_id:   product.id,
    amount_cents: product.price_cents,   // price comes from the database only
    currency:     product.currency,
    status:       'pending',
  });
  const session = await psp.createSession({
    amount:   order.amount_cents,
    currency: order.currency,
    metadata: { order_id: order.id },
  });
  res.json({ url: session.url });
});
</code></pre>
<p>Three lines of difference. But as long as the amount comes from <code>req.body</code>, any checks further down the code are pointless.</p>
<h2>Hole two: the "thank you" page confirms the payment</h2>
<p>The second common pattern: the user returns from the payment provider to <code>/success</code>, and access is granted right there. This breaks both ways. Close the tab right after the charge and the money is gone but access never gets granted, and you never find out. Or open <code>/success</code> directly, skip the payment entirely, and get access for free.</p>
<p>The only source of truth for a payment is the provider's webhook. And receiving it is not enough - you have to verify it: signature, timestamp, idempotency, and the order amount.</p>
<pre><code class="language-js">const crypto = require('crypto');
// express.raw is essential here: the signature is computed over the raw body.
// After JSON.parse and re-serialization the bytes are already different.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) =&gt; {
  const sig = req.get('X-Signature');
  const ts  = req.get('X-Timestamp');
  if (!sig || !ts) return res.sendStatus(400);
  // 1. Replay: a valid webhook intercepted once should not be replayable tomorrow
  if (Math.abs(Date.now() / 1000 - Number(ts)) &gt; 300) return res.sendStatus(400);
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(`${ts}.${req.body}`)
    .digest('hex');
  // 2. Constant-time comparison. Plain === leaks timing information:
  //    the signature can be brute-forced byte by byte from the rejection speed.
  const ok = sig.length === expected.length &amp;&amp;
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.sendStatus(403);
  const event = JSON.parse(req.body);
  // 3. Idempotency: the provider retries until it gets a 200.
  //    Without this check, one payment extends a subscription three times over.
  if (await events.exists(event.id)) return res.sendStatus(200);
  // 4. Reconcile the amount against our own order, never trust the event alone
  const order = await orders.get(event.metadata.order_id);
  if (!order ||
      order.amount_cents !== event.amount_cents ||
      order.currency     !== event.currency) {
    await alerts.send('payment_mismatch', { event });
    return res.sendStatus(409);
  }
  await events.save(event.id);
  await orders.markPaid(order.id);
  res.sendStatus(200);
});
</code></pre>
<p>By default, an assistant does, at best, the first of these five checks - signature verification. Idempotency, constant-time comparison, and amount reconciliation have to be asked for explicitly, one by one. Neither hole gets caught by ordinary testing: tests check "paid -&gt; got access" and "did not pay -&gt; no access", and an attacker is interested in the third path nobody on the review side thought to check.</p>
<h2>Why the model defaults to this</h2>
<p>I am not a senior engineer - I build products with AI, and the first thing that taught me is that the most dangerous thing is not bad code, it is code that looks like it works.</p>
<p>A model reproduces the most common pattern from what it was trained on, and in tutorials and examples the check is almost always on the client - it is shorter and easier to demonstrate. It implements what is visible in the interface, because the interface is what you described to it.</p>
<p>So for critical features - payments, access, other people's data - my first request is never about code:</p>
<blockquote>
<p>"Don't write code yet. Look at the integration docs, the security standards, and the common vulnerabilities (especially price tampering and payment validation). Give me 2-3 ways to do this safely, list the trade-offs of each, and tell me which you recommend and why."</p>
</blockquote>
<p>The holes get closed before any code exists - reworking the architecture afterward costs ten times as much. And the side effect turned out to matter more than the main one: you start actually understanding how your own product works under the hood, and you choose the approach deliberately. You do not need this for every button. For payments, it is not optional.</p>
<h2>Attacking your own product before handover</h2>
<p>Before handing off a project with payments, I open a clean session - not the context the code was written in - give the agents access to the result, and one task: bypass the payment.</p>
<p>The clean session is essential. An agent that knows "how it was meant to work" defends the design and explains why it is correct. An agent that only sees the code looks for a way to fool it.</p>
<p>That time, they found a bypass the first audit had missed.</p>
<p>On backups, separately: the habit started after a day an AI agent, tweaking something minor on a server, took down a client's live site. A snapshot taken before the work started saved it - three minutes, and the site was back. Since then, a backup is the first step of any task, no exceptions.</p>
<h2>What we run around payments</h2>
<p>Full protection does not exist. The goal is different: make an attack cost more than whatever it could gain.</p>
<p><strong>Log everything.</strong> Every request, every operation. Looks paranoid right up until the first incident review.</p>
<p><strong>Automatically block suspicious IPs</strong> - bots, spammers, odd request patterns.</p>
<p><strong>Rate-limit spend per account</strong> - a sliding window on request count and on money spent:</p>
<pre><code class="language-js">// Count money as well as requests: 10 expensive calls
// hit the wallet harder than 1,000 cheap ones.
async function guard(accountId, costCents) {
  const WINDOW = 3600;
  const now = Math.floor(Date.now() / 1000);
  const key = `spend:${accountId}`;
  await redis.zremrangebyscore(key, 0, now - WINDOW);
  await redis.zadd(key, now, `${now}:${crypto.randomUUID()}:${costCents}`);
  await redis.expire(key, WINDOW);
  const entries  = await redis.zrange(key, 0, -1);
  const requests = entries.length;
  const spent    = entries.reduce((s, e) =&gt; s + Number(e.split(':')[2]), 0);
  const limit = await limits.get(accountId);
  if (requests &gt; limit.requests_per_hour || spent &gt; limit.cents_per_hour) {
    await accounts.block(accountId, 'anomaly');
    await alerts.send('account_blocked', { accountId, requests, spent });
    return false;
  }
  return true;
}
</code></pre>
<p>This fires in two cases: either someone found a genuine vulnerability, or a stranger is running requests against a paid AI API on someone else's account - they found an endpoint that proxies calls to the model past the interface and its limits, and is using it as a free ChatGPT. Either way, the response is the same: stop first, investigate after.</p>
<p><strong>Real-time alerts.</strong> Not "find out from the logs a week later" - see it now.</p>
<p><strong>A kill switch.</strong> A script that cuts every external connection: no data, no API, and a notification to me.</p>
<p>It sounds dramatic for a product built by one person. But over-engineering this ten times over is still cheaper than getting it wrong once with a client's money.</p>
<h2>The honest part</h2>
<p>AI agents do not replace a pentest. They do not see the full infrastructure, do not know the business context, and confidently claim there is nothing wrong when they simply found nothing - every finding still needs a human check. It is a cheap first filter, not a security audit.</p>
<p>And the code above does not make a product unbreakable. It closes two specific places where mistakes happen most often.</p>
<hr />
<p>More on how we build this at <a href="https://hikmah-labs.dev/en/">hikmah-labs.dev/en/</a> - a studio that builds web apps, bots, and AI agents, with payment and access security handled as a first-class part of the build, not an afterthought.</p>
]]></content:encoded></item><item><title><![CDATA[AI Cross-Posting: Why One Post Does Not Fit Every Platform]]></title><description><![CDATA[We are building PostPilot, a B2B cross-posting platform: a post is written once and goes out on every connected platform, with AI adapting the text to each platform's format. The product is live, curr]]></description><link>https://hikmahlabs.hashnode.dev/ai-cross-posting-why-one-post-does-not-fit-every-platform</link><guid isPermaLink="true">https://hikmahlabs.hashnode.dev/ai-cross-posting-why-one-post-does-not-fit-every-platform</guid><category><![CDATA[AI]]></category><category><![CDATA[automation]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[kh.vibecoding]]></dc:creator><pubDate>Fri, 11 Sep 2026 07:13:33 GMT</pubDate><content:encoded><![CDATA[<p>We are building PostPilot, a B2B cross-posting platform: a post is written once and goes out on every connected platform, with AI adapting the text to each platform's format. The product is live, currently in stage three of three, with several platforms already connected. Here is what "adapting to a platform" actually means, and why simply duplicating a post does not solve the problem.</p>
<h2>Why "one post everywhere" does not work</h2>
<p>The first idea anyone has about cross-posting is to copy a post and push it to every channel with one button. It is the simplest technical solution, which is exactly why it delivers so little. Every platform reads differently: some show the whole text at once, some show only the first lines before the reader decides whether to keep going, some treat the headline as a separate interface element rather than the first line of the body. The same post, dropped unchanged into every format, either gets cut in the wrong place or reads as foreign on a platform it was never written for.</p>
<p>Duplication solves "the text physically exists on every platform." It does not solve "the text gets read." Those are different problems, and a business paying to maintain a presence across several channels needs the second one solved.</p>
<h2>What actually changes during adaptation</h2>
<p>In PostPilot, the same source post does not go out unchanged - AI reworks it for the format of each specific channel. Our project card states this directly: headlines and framing change per platform. That means the headline for a platform where it exists as a separate element does not match the first line of the text a reader sees on a platform with no headlines at all. Framing is how the text is built - what comes first, what gets pulled forward, and what can be cut when a platform's format is not built for long text.</p>
<p>We want to be precise about what we are claiming here and what we are not. We will not list a specific set of technical adaptation parameters - character length, tone, markup - beyond what the project card states: the headline and the framing change for each platform. That alone is a non-trivial problem: the system has to understand the format of every connected platform and rewrite the text for it while keeping the author's point intact, rather than turning the adaptation into a different text with a different meaning.</p>
<h2>Why a publishing queue, not just a broadcast</h2>
<p>Cross-posting to one channel is a "publish" function. Cross-posting to several platforms is a process: a post has to go through adaptation for each platform, join a publishing queue, and go out at the right time on each of them, without losing a copy or duplicating one. If any one of those steps is built carelessly, the outcome is predictable - a post goes out late on one platform, never goes out on another, and goes out twice on a third. For a B2B client paying specifically to avoid manual publishing, any of those failures undermines the whole product, regardless of how good the text adaptation itself is, if the post never actually reached the platform.</p>
<h2>Architecture built for growth</h2>
<p>The project card states separately that the architecture is built to scale with volume. For a B2B cross-posting platform that is a real requirement, not a throwaway line. A client connects platforms gradually rather than all at once - starting with one or two, confirming the text comes out the way it should, and only then adding the next ones. If the architecture is tightly bound to a fixed set of platforms or to current publishing volume, every expansion becomes a rebuild instead of a configuration change. We built the architecture so that connecting a new platform and growing the number of posts do not require rebuilding the system from scratch - that is what we can confirm on our side, without disclosing the client's specific technical implementation.</p>
<h2>What a B2B client actually gets</h2>
<p>The practical result for a business maintaining a presence across several platforms: a post is written once instead of being rewritten by hand for each platform separately. That saves the editor's or marketer's time otherwise spent fitting the same message into different formats. Several platforms are already connected and working - this is not a single-platform prototype but a system built for a company's real workflow, where a multi-channel presence is not a one-off campaign but a permanent part of talking to an audience.</p>
<h2>When you do not need this</h2>
<p>A product like PostPilot solves a specific problem, and it has boundaries:</p>
<ul>
<li><p><strong>A single platform.</strong> If a company maintains a presence on only one channel, the task of "adapt to several formats" simply does not exist. Cross-posting with adaptation solves the problem of multiple platforms - where there is only one, there is nothing here worth paying for.</p>
</li>
<li><p><strong>Infrequent posts.</strong> If posts go out once every week or two, manually adapting a post per platform takes 15-20 minutes - not enough time to justify paying for automation and maintaining platform integrations for that volume.</p>
</li>
<li><p><strong>Content that needs a hand-crafted presentation.</strong> Some formats are not primarily about text: posts with complex layout, an authorial voice that needs line-by-line control, material where any automatic change to framing reads to the audience as a loss of authorship. For content like that, an adaptation system gets in the way rather than helping - manual publishing or an editor who adjusts the text per platform themselves works better.</p>
</li>
</ul>
<p>If you fall into one of these three cases, the honest advice is not to build a cross-posting platform - solve it more simply, with a single channel, occasional manual posting, or a human editor with full control.</p>
<h2>How we work on projects like this</h2>
<p>AI agents and integrations at this level start from \(1,400, usually 3-5 weeks, with the price fixed in the technical spec before the start and no increases along the way. If the scope and architecture are not obvious upfront, there is a discovery phase: \)170, 3-5 days, a written report with risks and an exact price - the amount counts toward the project. We work in stages, each ending with a demo of a working part of the system, 50% upfront, and 90 days of free fixes after handover. Project data is under NDA by default.</p>
<p>We are Hikmah Labs, a small studio building AI agents, automation and web products for businesses. More about us at <a href="https://hikmah-labs.dev/en/">https://hikmah-labs.dev/en/</a>.</p>
]]></content:encoded></item><item><title><![CDATA[ProxyKey MCP: Giving an AI Agent API Access Without Handing Over a Key]]></title><description><![CDATA[We already wrote about why a credential proxy exists and why "zero-knowledge" is impossible for one by design - that proxy is ProxyKey, our own project. This post is the practical follow-up: how to co]]></description><link>https://hikmahlabs.hashnode.dev/proxykey-mcp-giving-an-ai-agent-api-access-without-handing-over-a-key</link><guid isPermaLink="true">https://hikmahlabs.hashnode.dev/proxykey-mcp-giving-an-ai-agent-api-access-without-handing-over-a-key</guid><category><![CDATA[AI]]></category><category><![CDATA[Security]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[kh.vibecoding]]></dc:creator><pubDate>Fri, 11 Sep 2026 07:09:42 GMT</pubDate><content:encoded><![CDATA[<p>We already wrote about why a credential proxy exists and why "zero-knowledge" is impossible for one by design - that proxy is ProxyKey, our own project. This post is the practical follow-up: how to connect Claude Code or Cursor to ProxyKey over MCP, what the 13 available tools are, why "read the key" is deliberately not one of them, and how the scenario we built all of this for actually plays out - an agent deploying a bot that doesn't have a token yet.</p>
<h2>Why an agent needs MCP to a vault, not just a key in .env</h2>
<p>An agent like Claude Code or Cursor lays out environment variables, writes configs, and sometimes logs what it's doing. Anything that enters a model's context has to be treated as published: context gets logged, traced, and can be pulled out through prompt injection. Handing that agent a live OpenAI key or a Telegram bot token is functionally the same as handing it to a random script on the internet.</p>
<p>The ProxyKey MCP server solves this at the protocol level, not with a policy the agent is trusted to follow. The agent gets a tool, not a secret. None of the 13 methods has an operation that returns a key's value. The agent can create, revoke, and rotate passes, and inspect limits and request logs - but it cannot read the original, because that endpoint simply doesn't exist in the API.</p>
<h2>Connecting: Claude Code and Cursor</h2>
<p>The first step is a human one: sign in at app.proxykey.org (GitHub OAuth or a magic link, free), open the MCP section, and create a token in the form <code>mcp_...</code>. Only that token goes to the agent - never the real provider keys.</p>
<p>For Claude Code, one command:</p>
<pre><code class="language-bash">claude mcp add --transport http proxykey https://mcp.proxykey.org/mcp \
  --header "Authorization: Bearer mcp_YOUR_TOKEN"
</code></pre>
<p>For Cursor and Claude Desktop, an entry in <code>mcp.json</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "proxykey": {
      "url": "https://mcp.proxykey.org/mcp",
      "headers": { "Authorization": "Bearer mcp_YOUR_TOKEN" }
    }
  }
}
</code></pre>
<p>From there the agent sees ProxyKey's tools like any other MCP tools and can call them on its own, without a human in the loop on every step.</p>
<h2>13 tools - and why "read the key" is not one of them</h2>
<ul>
<li><p><strong>Catalogue and metadata</strong>: <code>list_providers</code> lists providers and their auth model. <code>list_secrets</code> shows stored keys, metadata only, never values. <code>get_manual_secret_setup</code> returns a link for a human to enter the real key.</p>
</li>
<li><p><strong>Pass management</strong>: <code>create_pass</code>, <code>create_pending_pass</code>, <code>update_pass</code>, <code>rotate_pass</code>, <code>revoke_pass</code>, <code>delete_pass</code>, <code>rebind_pass_ip</code> - the full lifecycle of a virtual token.</p>
</li>
<li><p><strong>Observability</strong>: <code>list_passes</code>, <code>get_pass_logs</code>, <code>get_pass_stats</code> - the agent sees each pass's status, limits, IP binding, and request history.</p>
</li>
</ul>
<p>None of the 13 operations has a parameter that returns a secret's value. This isn't "the agent has agreed not to look" - that capability simply isn't in the API contract. For the same reason, the MCP surface can't turn on request-body logging either - that stays human-only in the panel, because otherwise a key could be reconstructed indirectly through the logs.</p>
<h2>The pending-secret scenario: an agent deploys a bot without a token</h2>
<p>This is the exact reason we built all of this. A common situation: an agent is deploying a Telegram bot, writing the code, wiring up the webhook - but there's no BotFather token yet, because the bot hasn't been created.</p>
<p>Instead of stalling and waiting on a human, the agent calls <code>create_pending_pass</code>. The pass (<code>vlt_...</code>) is issued immediately and can go straight into the bot's config - but proxying real traffic is blocked with the status <code>original_key_required</code> until the secret is filled in. The agent then calls <code>get_manual_secret_setup</code> and hands the resulting link to a human.</p>
<p>The human opens the panel, pastes in the real token once, and the pass activates automatically - no second call from the agent required. The bot comes alive. At no point did the agent see the secret's value; it went through the panel, not through the model's context.</p>
<h2>What a human sees in the panel</h2>
<p>The panel is the only place a real key's value ever lands - the form used for initial setup or for filling in a pending secret. From there the interface shows the list of stored secrets (metadata only, no values), the list of passes with their status, IP binding and limits, and a per-pass request log - metadata, with no authorization headers and no key material.</p>
<p>The split is simple: anything that could expose a secret's value is human-only, through the panel. Anything the agent needs for day-to-day work - issuing, revoking, rotating, monitoring - is available over MCP.</p>
<h2>The proxy call itself</h2>
<p>Once a pass is issued, the application or agent points at the proxy instead of the provider - only the host and the key change; the path and body stay the same:</p>
<pre><code class="language-bash"># before
curl https://api.openai.com/v1/chat/completions -H "Authorization: Bearer sk-..."
# after
curl https://api.proxykey.org/p/openai/v1/chat/completions -H "Authorization: Bearer vlt_openai_..."
</code></pre>
<p>Streaming (SSE), request bodies, and headers pass through unchanged. Telegram bots keep their usual URL shape: <code>/p/telegram-bot/&lt;pass&gt;/getMe</code>.</p>
<h2>A limitation worth stating plainly</h2>
<p>A hosted proxy cannot be zero-knowledge by design: to put a key into a request to the provider, the proxy has to decrypt it in memory at the moment it handles that request. Which means a process with full access - and, by extension, the service operator - can in principle obtain the plaintext. That isn't a bug specific to ProxyKey; it's a property of every hosted solution in this category. We covered this in detail on the <a href="https://proxykey.org/en/security/">security page</a>: what the encryption actually protects against (a database leak, a stolen backup, a log leak) and what it doesn't (a fully compromised server). The agent's MCP access doesn't add new risk on top of that - if anything it's more restricted than a human's access through the panel.</p>
<h2>When you don't need this</h2>
<ul>
<li><p><strong>You have one key and one consumer.</strong> If a key is used in a single place you fully control and no agent ever touches it, a proxy just adds a point of failure with no real upside.</p>
</li>
<li><p><strong>Your threat model won't tolerate a third party in the request chain.</strong> The proxy sees the traffic, even if it only logs metadata. If that's unacceptable, run your own instance of the pattern, or skip it entirely.</p>
</li>
<li><p><strong>Latency in the single-digit milliseconds actually matters.</strong> The extra hop plus a validation-cache lookup adds overhead. For LLM calls it's invisible against the generation time; for some low-latency, non-LLM APIs it can matter - worth running the numbers yourself.</p>
</li>
</ul>
<h2>The takeaway</h2>
<p>The model is simple: a secret enters the system exactly once, through the panel, and never leaves the server in plaintext again. The agent gets a tool with a deliberately narrow contract - everything it needs to automate issuing and managing access, and nothing that could leak through the model's context. For agents that deploy their own services and bots, that removes the usual blocker - what to do about a key the agent doesn't have yet - without a human on every step.</p>
<hr />
<p>We build ProxyKey at <a href="https://hikmah-labs.dev/en/">Hikmah Labs</a>, a small studio. You can read more about our work there.</p>
]]></content:encoded></item><item><title><![CDATA[Building Voice AI Agents for a Call Centre: Two Case Studies]]></title><description><![CDATA[We have two voice agents in our portfolio built for different jobs: one answers inbound calls for a financial services firm, the other calls customers for a logistics company and collects requests. We]]></description><link>https://hikmahlabs.hashnode.dev/building-voice-ai-agents-for-a-call-centre-two-case-studies</link><guid isPermaLink="true">https://hikmahlabs.hashnode.dev/building-voice-ai-agents-for-a-call-centre-two-case-studies</guid><category><![CDATA[AI]]></category><category><![CDATA[automation]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[kh.vibecoding]]></dc:creator><pubDate>Fri, 11 Sep 2026 07:05:48 GMT</pubDate><content:encoded><![CDATA[<p>We have two voice agents in our portfolio built for different jobs: one answers inbound calls for a financial services firm, the other calls customers for a logistics company and collects requests. We already wrote about the economics of voice agents elsewhere. This one is about the process itself - how we scoped the task, what decisions we made along the way, what turned out harder than it looked at the start, and what the client ended up seeing on the dashboard.</p>
<h2>Two different jobs under one label</h2>
<p>"Voice agent" is a broad label for things that work quite differently under the hood. The financial firm's task was inbound: the customer calls in, the agent answers routine questions, and hands the complex ones to an operator. The logistics company's task was outbound as much as inbound: the agent both answers calls and calls people from a list to clarify details or collect a request. Both talk in a natural voice and understand ordinary speech, but the scenarios built into them and the data they work with are different. So even though the projects sit under the same theme, they were put together differently from day one.</p>
<h2>Where we started: mapping scenarios and policies</h2>
<p>The first step on both projects was not about technology - it was about what actually happens on a call. We work through the call scenarios and the company's policies: what customers ask, what rules operators follow when they answer, where a routine answer ends and an individual case begins. For the support agent, that meant turning the company's policies into a form the agent could actually answer from, without inventing wording of its own. For the calling agent, it meant deciding which fields a conversation absolutely has to fill in for the resulting request to be usable, rather than a string of disconnected phrases.</p>
<p>What comes out of this step is a line: what the agent takes on, and what stays with people. That line gets fixed in the technical spec along with price and timeline before any work starts, with an NDA where needed.</p>
<h2>What turned out to be hard in practice</h2>
<p>The hard part of either project was never getting the agent to talk - that part is solvable. The hard part is the boundary and the handoff.</p>
<p>For the support agent, it was teaching it to honestly recognise the moment a question falls outside its policies, rather than guessing. An agent that confidently gives a wrong answer to a financial firm's customer is worse than one that admits, in time, that the question is not its to answer and calls in an operator.</p>
<p>For the calling agent, it was keeping one underlying logic for both inbound and outbound calls, with different scenarios inside each, while still producing data in the same structured shape regardless of who started the conversation. A call the customer starts and a call the agent starts read very differently - the record that comes out of either has to be equally usable.</p>
<p>And one principle applied to both: the agent does not pass itself off as a human. It introduces itself as an automated assistant. We treated that as a matter of trust in the client's brand rather than a technical constraint - the pretence gets uncovered quickly and damages the impression more than talking to a machine ever would.</p>
<h2>How context reaches the operator</h2>
<p>When the support agent hands a call to a person, the conversation history goes with it - what the customer has already said and what the agent already clarified. The customer does not have to repeat everything to an operator who just picked up. For a call centre where patience runs out faster on the second explanation than on the first, this was a requirement from the start of the build, not an option added later.</p>
<h2>What went into the dashboard</h2>
<p>The two dashboards were built around different questions a manager actually asks. The support agent's dashboard shows calls, topics and load: how many calls came in, what people ask about most, how load is distributed over time. The calling agent's dashboard shows calls, requests and outcomes: how many conversations happened, how many turned into a request, and how each call ended. In both cases the goal is the same - the manager sees the raw picture of what is happening on the line for the first time, rather than an operator's summary of it.</p>
<h2>What handover looked like</h2>
<p>Both projects ran in stages, each ending with a demo of the live agent - something you can actually call and talk to, not a slide deck. Price was fixed in the technical spec before the start and did not change along the way. After handover, we fix issues free for 90 days, then move to retainer support if the client wants it.</p>
<h2>Where this approach does not fit</h2>
<ul>
<li><p><strong>The company has no written policies yet.</strong> If operators' answers live in their heads rather than in a documented set of rules, the agent has nothing to work from. Turning that knowledge into a form the agent can answer from is a separate task that has to happen before the agent itself gets built.</p>
</li>
<li><p><strong>Every call needs legal or medical judgement.</strong> Where the cost of a wrong answer is high, the agent should honestly flag that risk during discovery, and the project should at most capture a contact for a specialist rather than try to replace the consultation.</p>
</li>
<li><p><strong>There is no one to pick up escalations quickly.</strong> A dashboard full of topics and load is useless if transferred calls have no one to answer them - the agent removes load from the phone line, but the bottleneck just moves to an already overloaded operator.</p>
</li>
</ul>
<p>We build voice AI agents and other AI-driven automation at Hikmah Labs. More about the studio at <a href="https://hikmah-labs.dev/en/">https://hikmah-labs.dev/en/</a>.</p>
]]></content:encoded></item></channel></rss>