Engineering

Taming Hyper-Local Complexity with AI Agents

Taming Hyper-Local Complexity with AI Agents

I spent forty-five minutes last Tuesday fighting a public service portal to parse a single .hwp document, only to realize I then had to book an SRT train ticket on a mobile app that seemingly hates my thumbs. Every developer living in Korea has this exact breakdown at least once a year. We build highly scalable distributed systems during the day, yet we are brought to our knees by a train booking interface or a local government website at night.

I got tired of doing this manually. I wanted to just tell an AI agent: "Check the KBO baseball game results, then book a train to Busan if my team won. Afterward, figure out the delivery status of my latest package."

The language models we have today are absolutely smart enough to plan this out. The missing piece is the execution layer. They lack the localized tools, the specific API wrappers, to actually reach out and touch these uniquely regional services.

The Systems View of Agent Tooling

An LLM is essentially a reasoning engine trapped in a box. It only knows what you feed it, and it can only act through the functions you explicitly expose to it. When I looked at existing agent toolkits, almost all of them were heavily focused on global platforms. They prioritized integrations for GitHub and Google Drive, alongside Slack.

Nobody was building tools for the uniquely agonizing local infrastructure I deal with daily. This includes the SRT ticketing systems and the Seoul Subway arrival APIs. I also needed to handle the weird XML responses of government portals and the proprietary nightmare that is the Hangul Word Processor (HWP) format.

I decided to build my own bridge. I started writing a modular toolkit of local skills specifically designed to be injected into agents like Claude Code or Codex. The goal was to decouple the reasoning engine (the LLM) from the execution engine (the specific local API).

Designing the Skill Interface

If you want an AI to reliably execute a task, handing it a raw REST endpoint and hoping for the best is a recipe for disaster. You need a strict, typed contract. The agent needs to know exactly what parameters are required and what they mean. It also must understand what the failure states look like.

I settled on a base architecture using TypeScript and Zod for runtime validation. This allows me to automatically generate the JSON Schema required by OpenAI and Anthropic for function calling.

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
 
export interface AgentSkill<T extends z.ZodTypeAny, R> {
  name: string;
  description: string;
  schema: T;
  execute: (args: z.infer<T>) => Promise<SkillResponse<R>>;
}
 
export type SkillResponse<T> = 
  | { success: true; data: T; message?: string }
  | { success: false; error: string; retryable: boolean };
 
// A helper to register skills into the format LLMs expect
export function exportToOpenAITool(skill: AgentSkill<any, any>) {
  return {
    type: "function",
    function: {
      name: skill.name,
      description: skill.description,
      parameters: zodToJsonSchema(skill.schema)
    }
  };
}

The description string inside the tool definition is arguably more important than the code itself. This is the instruction manual for the LLM. If you write sloppy descriptions, the model will hallucinate arguments or call the tool at the wrong time.

Implementing the Subway Arrival Skill

Let's look at a concrete example. The Seoul Metropolitan Government provides an API for real-time subway arrivals. The raw data returned from this API is chaotic. It contains dozens of redundant fields and confusing status codes. You also have to deal with mixed encodings.

If you pipe the raw response directly back to the LLM, you waste thousands of tokens and thoroughly confuse the model. The job of the Skill wrapper is to normalize that data into a clean, deterministic state.

const SubwayQuerySchema = z.object({
  stationName: z.string().describe("The exact name of the subway station in Korean, e.g., '강남', '역삼'"),
  line: z.string().optional().describe("Optional line number or name to filter by, e.g., '2호선'")
});
 
interface NormalizedArrival {
  direction: string;
  destination: string;
  arrivalTimeSeconds: number;
  currentStatus: string;
}
 
export const SeoulSubwaySkill: AgentSkill<typeof SubwayQuerySchema, NormalizedArrival[]> = {
  name: "check_seoul_subway_arrivals",
  description: "Fetches real-time subway arrival information for a specific station in Seoul.",
  schema: SubwayQuerySchema,
  execute: async (args) => {
    try {
      // The actual API requires URL encoding and specific key placement
      const encodedStation = encodeURIComponent(args.stationName);
      const url = `http://swopenapi.seoul.go.kr/api/subway/${process.env.SEOUL_API_KEY}/json/realtimeStationArrival/0/5/${encodedStation}`;
      
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      
      const rawData = await response.json();
      
      // AI models struggle with the raw API structure. We map it to something predictable.
      const cleanData: NormalizedArrival[] = rawData.realtimeArrivalList.map((item: any) => ({
        direction: item.updnLine,
        destination: item.bstatnNm,
        arrivalTimeSeconds: parseInt(item.barvlDt, 10),
        currentStatus: item.arvlMsg2
      }));
 
      const filtered = args.line 
        ? cleanData.filter(d => d.direction.includes(args.line!))
        : cleanData;
 
      return { success: true, data: filtered };
    } catch (error) {
      return { 
        success: false, 
        error: "Failed to fetch transit data. The API might be down or the station name is invalid.",
        retryable: true
      };
    }
  }
};

I wrote this specific implementation after watching Claude repeatedly fail to understand which direction the "Outer Circle" line was heading based on the raw government XML. By explicitly filtering the payload before handing it back to the agent, the success rate for transit queries hit 100%.

The State Mutation Dilemma

Reading data is relatively painless. Mutating state, like booking a non-refundable train ticket or querying personal tax data via HomeTax, is where things get stressful.

Ticketing systems usually require complex session management. You have to log in and maintain encrypted cookies. From there, you must query schedules and execute a booking sequence in a very specific order. You absolutely do not want an autonomous agent guessing its way through a payment gateway.

My approach here was to create a rigid state machine for the booking process and only expose specific transitions to the agent.

const BookTrainSchema = z.object({
  departureStation: z.string(),
  arrivalStation: z.string(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  timeThreshold: z.string().describe("Preferred departure time, e.g., '14:00'")
});
 
export const SrtBookingSkill: AgentSkill<typeof BookTrainSchema, { reservationId: string, status: string }> = {
  name: "reserve_srt_ticket",
  description: "Creates a pending reservation for an SRT train ticket. DOES NOT process payment. A human must manually pay after reservation.",
  schema: BookTrainSchema,
  execute: async (args) => {
    // 1. Initialize headless session
    const session = await SrtClient.login(process.env.SRT_ID!, process.env.SRT_PASSWORD!);
    
    // 2. Search schedules
    const schedules = await session.search({
      dep: args.departureStation,
      arr: args.arrivalStation,
      date: args.date
    });
 
    // 3. Find closest match to timeThreshold
    const targetTrain = findClosestTrain(schedules, args.timeThreshold);
    if (!targetTrain) {
      return { 
        success: false, 
        error: "No available seats around the requested time.", 
        retryable: false 
      };
    }
 
    // 4. Reserve (Add to cart, essentially)
    const reservation = await session.reserve(targetTrain.id);
    
    return {
      success: true,
      data: {
        reservationId: reservation.id,
        status: "PENDING_PAYMENT"
      },
      message: `Successfully reserved train at ${targetTrain.departureTime}. Please open the mobile app to finalize payment within 20 minutes.`
    };
  }
};

Notice how the execute function deliberately avoids charging a credit card. It prepares the cart and returns a confirmation payload. The agent can then tell the user, "I have secured a seat on the 14:30 train to Busan. Please confirm on your app to finalize payment." This human-in-the-loop design prevents the AI from accidentally buying ten tickets because it got caught in an automated retry loop.

Looking Ahead: The Document Boss Fight

The tools I currently have running locally are mostly standard HTTP requests. These include subway times and baseball scores, along with lotto numbers. The next hurdle I am working on is HWP parsing and KakaoTalk integration.

Dealing with .hwp files programmatically is a rite of passage for Korean developers. The format is notoriously complex and heavily guarded. My plan is to build a skill that uses a background Python process to convert the proprietary binary into clean Markdown, which the TypeScript agent layer can then pass to the LLM.

For messaging platforms like KakaoTalk, the approach will likely involve a local bridge that interacts with the desktop client's accessibility APIs, since official bot APIs are locked down for personal accounts. It feels incredibly hacky, but that is the reality of building agent tools for closed ecosystems.

The Value of Localized Abstractions

We spend so much time marveling at the general intelligence of new AI models that we forget they still need hands. General-purpose agents fail at hyper-local tasks because the internet is not standardized. The real world is full of regional quirks and deprecated endpoints. It is also packed with bizarre security requirements.

Writing these wrappers forced me to realize that the next phase of software engineering demands more than crafting the perfect prompt. We must build reliable, highly specific APIs that computers can use to interact with the messy legacy systems humans built.

If you want your AI to actually help you with your daily life, stop waiting for companies in San Francisco to build an integration for your local transit system. Write the TypeScript interface and wrap the messy endpoints. This will give the agent the tools it needs to do the heavy lifting for you.